From b52d8899677a2635cab4e7f313d576288dc4b3eb Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Fri, 12 Jun 2026 13:48:52 +0400 Subject: [PATCH 001/119] docs: add charts and shape primitives design spec --- .../2026-06-12-charts-and-shapes-design.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-12-charts-and-shapes-design.md diff --git a/docs/superpowers/specs/2026-06-12-charts-and-shapes-design.md b/docs/superpowers/specs/2026-06-12-charts-and-shapes-design.md new file mode 100644 index 0000000..7119ad9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-charts-and-shapes-design.md @@ -0,0 +1,172 @@ +# Charts and Shape Primitives for LLM-Generated Graphics + +Enable LLM agents to generate polished charts and custom graphics through declarative YAML templates, rendered via Skia. No hand-written SVG markup required. Two layers: high-level `chart` elements (agent describes data, library draws it beautifully) and low-level shape primitives (`rect`, `circle`, `ellipse`, `canvas`) for custom decoration and free-form drawing. + +## Goals + +- An agent picks a chart type, supplies data arrays, and optionally one theme/palette word — the output looks professional with zero styling decisions. +- Shape primitives cover dashboard decoration (status dots, badges, dividers) and arbitrary line/path drawing. +- Zero new external dependencies: all math and drawing use existing Core + SkiaSharp. +- Follows existing patterns: AST in `FlexRender.Core`, rendering in `FlexRender.Skia`, switch-based element dispatch, `KnownProperties.cs` registration, resource limits. + +## Architecture + +Charts and shapes have no external dependencies, so — unlike QrCode/Barcode — no new packages are needed: + +- **AST**: new element classes in `src/FlexRender.Core/Parsing/Ast/` (`RectElement`, `CircleElement`, `EllipseElement`, `CanvasElement`, `ChartElement`). +- **Chart layout math** (axis scales, nice ticks, legend measurement): `src/FlexRender.Core/Charts/` — pure, testable, renderer-agnostic. +- **Drawing**: `FlexRender.Skia` render visitors, same dispatch pattern as `TableElement`. +- **Parsing**: `FlexRender.Yaml` `TemplateParser` extended for new element types; all new properties registered in `KnownProperties.cs` for validation and typo suggestions. + +## Layer 1: Shape Primitives + +### Box shapes (participate in flex layout) + +`rect`, `circle`, `ellipse` — regular flex boxes with `width`/`height` (or `size` shorthand for circle). + +| Property | Type | Description | +|---|---|---| +| `fill` | string or object | Solid color (`"#4A90D9"`) or gradient | +| `stroke` | string | Stroke color | +| `stroke-width` | number | Stroke width in px | +| `opacity` | number | 0..1 | +| `radius` | unit | Corner radius (rect only) | + +Gradient object form: + +```yaml +fill: + gradient: linear # linear | radial + colors: ["#f00", "#00f"] + angle: 45 # linear only, degrees +``` + +### `canvas` — free-form drawing + +A flex box in the flow; inside, a `shapes` list in absolute coordinates relative to the canvas box: + +```yaml +- type: canvas + width: 400 + height: 200 + shapes: + - line: {x1: 0, y1: 100, x2: 400, y2: 50, stroke: "#333", stroke-width: 2} + - polyline: {points: [[0, 10], [50, 40], [100, 20]], stroke: "#4A90D9"} + - rect: {x: 10, y: 10, width: 80, height: 40, fill: "#eee", radius: 4} + - circle: {cx: 200, cy: 75, r: 30, fill: "#e74c3c"} + - path: {d: "M 0 0 L 100 50 Q 150 0 200 50 Z", fill: "#2ecc71"} +``` + +- `path.d` supports commands `M`, `L`, `Q`, `C`, `Z` (absolute coordinates only). Parsed with a hand-written tokenizer — no regex backtracking, AOT-safe. +- Shapes draw in list order (painter's algorithm); overlap is allowed. +- New resource limit: `ResourceLimits.MaxShapesPerCanvas` (default 1000). Existing `MaxNestingDepth` applies to element tree as usual. + +## Layer 2: Chart Element + +```yaml +- type: chart + chart-type: bar # see list below + width: 600 + height: 300 + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: "{{ sales }}" # array from data context, like table rows + - label: "2025" + data: [12, 30, 22, 48] + palette: ocean # optional, default palette otherwise + legend: bottom # top | bottom | left | right | none (default: bottom when >1 series, else none) + title: "Revenue" # optional +``` + +The library computes everything visual: axis ranges, nice tick values, grid lines, labels, legend layout, padding, bar widths, point radii. The agent supplies only data and (optionally) one theme/palette word. + +### Chart types and phasing + +| Phase | Types | +|---|---| +| Charts base | `bar` (vertical + `horizontal: true`), `line`, `area`, `pie`, `donut` | +| Charts extension | `scatter`, `bubble`, `gauge`, `progress`, `sparkline` | +| Charts advanced | `heatmap`, `radar` | + +Type-specific properties (registered per type in `KnownProperties.cs`): + +- `bar`: `horizontal`, `stacked` +- `line`/`area`/`sparkline`: `smooth` (bool), `points` (bool, show markers) +- `pie`/`donut`: `labels` (percent | value | none) +- `bubble`: third value in data tuples for radius +- `gauge`/`progress`: `value`, `max`, `label` +- `heatmap`: 2D `data`, `x-labels`, `y-labels` +- `radar`: `categories` as spokes + +### Data binding + +`series[].data` accepts either an inline YAML array or a template expression resolving to an array from the data context — same `TemplateProcessor` path used by `table` and `each`. Numbers, or `[x, y]` / `[x, y, r]` tuples for scatter/bubble. + +## Themes and Palettes + +### Themes + +Named presets controlling fonts, grid color, background, bar corner radius, line widths, label sizes: `light` (default), `dark`, `minimal`. + +Set at template level with per-element override: + +```yaml +canvas: + width: 800 + theme: dark # template-wide +elements: + - type: chart + theme: light # override per chart +``` + +### Palettes + +Named series-color ramps: `default`, `ocean`, `sunset`, `forest`, `mono`, `vivid`. Also accepts an explicit color list: + +```yaml +palette: ["#264653", "#2a9d8f", "#e9c46a"] +``` + +With nothing specified, `light` theme + `default` palette produce a polished result — zero decisions required from the agent. + +Themes and palettes live in `FlexRender.Core/Charts/` as static readonly data (AOT-safe, no config files). + +## Error Handling + +- Unknown property / typo → existing validation with suggestions (`chart-typ` → "did you mean chart-type?"). All new properties registered in `KnownProperties.cs`. +- Empty or missing series data → render a "no data" placeholder inside the chart box, not an exception. The agent sees the image and understands. +- Non-numeric data values → template error with element path, consistent with existing table errors. +- Malformed `path.d` → parse error naming the offending command and position. +- Shape count over `MaxShapesPerCanvas` → resource limit error, same family as existing limits. Limits must never be weakened. + +## Testing + +- **Unit tests**: axis scale math (nice ticks for ranges crossing zero, negative-only, single point, identical values), palette/theme resolution, path tokenizer edge cases, data binding from context. +- **Snapshot tests**: golden images for every chart type × theme, shape primitives with gradients/strokes, canvas overlap ordering. Regenerated via `UPDATE_SNAPSHOTS=true`. +- **Validation tests**: typo suggestions for new properties, resource limit enforcement, empty-data placeholder. + +## Documentation (every phase) + +- `KnownProperties.cs` registration +- `llms.txt` / `llms-full.txt` +- Wiki: `Element-Reference.md`, `Visual-Reference.md`, `Cookbook.md` +- Template skill (`flexrender/skills/template/SKILL.md`) +- Playground JSON schema + autocomplete (`flexrender-template.json`) + +## Implementation Phases + +1. **Shapes**: `rect`/`circle`/`ellipse` box elements, gradients, `canvas` with `line`/`polyline`/`rect`/`circle`/`path`. +2. **Charts base**: `bar`/`line`/`area`/`pie`/`donut`, themes, palettes, axes, legends, "no data" placeholder. +3. **Charts extension**: `scatter`/`bubble`, `gauge`/`progress`/`sparkline`. +4. **Charts advanced**: `heatmap`/`radar`. +5. **FlexRender.Xml**: alternative XML template parser via existing `ITemplateParser` abstraction (LLMs write XML attributes more reliably than YAML indentation). Same AST, same elements; parser-level work only. + +Each phase ships independently with full tests and docs. + +## Out of Scope + +- SVG render backend for charts (Skia only initially; `FlexRender.Svg` backend support can follow later if needed). +- Animation, interactivity (static images by design). +- Arbitrary per-element style overrides beyond theme/palette (axis font sizes, custom grid styles) — themes are the styling surface; escape hatch is the `canvas` element. +- MCP server (agents use YAML + existing CLI; revisit after element work lands). From e265e7435836071b2d18bd70901f2b9df87c6069 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Fri, 12 Jun 2026 13:51:05 +0400 Subject: [PATCH 002/119] docs: rename canvas element to draw in charts spec --- .../2026-06-12-charts-and-shapes-design.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-06-12-charts-and-shapes-design.md b/docs/superpowers/specs/2026-06-12-charts-and-shapes-design.md index 7119ad9..3195606 100644 --- a/docs/superpowers/specs/2026-06-12-charts-and-shapes-design.md +++ b/docs/superpowers/specs/2026-06-12-charts-and-shapes-design.md @@ -1,6 +1,6 @@ # Charts and Shape Primitives for LLM-Generated Graphics -Enable LLM agents to generate polished charts and custom graphics through declarative YAML templates, rendered via Skia. No hand-written SVG markup required. Two layers: high-level `chart` elements (agent describes data, library draws it beautifully) and low-level shape primitives (`rect`, `circle`, `ellipse`, `canvas`) for custom decoration and free-form drawing. +Enable LLM agents to generate polished charts and custom graphics through declarative YAML templates, rendered via Skia. No hand-written SVG markup required. Two layers: high-level `chart` elements (agent describes data, library draws it beautifully) and low-level shape primitives (`rect`, `circle`, `ellipse`, `draw`) for custom decoration and free-form drawing. (The free-form element is named `draw`, not `canvas`, to avoid confusion with the template root `canvas:` settings section.) ## Goals @@ -13,7 +13,7 @@ Enable LLM agents to generate polished charts and custom graphics through declar Charts and shapes have no external dependencies, so — unlike QrCode/Barcode — no new packages are needed: -- **AST**: new element classes in `src/FlexRender.Core/Parsing/Ast/` (`RectElement`, `CircleElement`, `EllipseElement`, `CanvasElement`, `ChartElement`). +- **AST**: new element classes in `src/FlexRender.Core/Parsing/Ast/` (`RectElement`, `CircleElement`, `EllipseElement`, `DrawElement`, `ChartElement`). - **Chart layout math** (axis scales, nice ticks, legend measurement): `src/FlexRender.Core/Charts/` — pure, testable, renderer-agnostic. - **Drawing**: `FlexRender.Skia` render visitors, same dispatch pattern as `TableElement`. - **Parsing**: `FlexRender.Yaml` `TemplateParser` extended for new element types; all new properties registered in `KnownProperties.cs` for validation and typo suggestions. @@ -41,12 +41,12 @@ fill: angle: 45 # linear only, degrees ``` -### `canvas` — free-form drawing +### `draw` — free-form drawing -A flex box in the flow; inside, a `shapes` list in absolute coordinates relative to the canvas box: +A flex box in the flow; inside, a `shapes` list in absolute coordinates relative to the element box: ```yaml -- type: canvas +- type: draw width: 400 height: 200 shapes: @@ -59,7 +59,7 @@ A flex box in the flow; inside, a `shapes` list in absolute coordinates relative - `path.d` supports commands `M`, `L`, `Q`, `C`, `Z` (absolute coordinates only). Parsed with a hand-written tokenizer — no regex backtracking, AOT-safe. - Shapes draw in list order (painter's algorithm); overlap is allowed. -- New resource limit: `ResourceLimits.MaxShapesPerCanvas` (default 1000). Existing `MaxNestingDepth` applies to element tree as usual. +- New resource limit: `ResourceLimits.MaxShapesPerDraw` (default 1000). Existing `MaxNestingDepth` applies to element tree as usual. ## Layer 2: Chart Element @@ -143,7 +143,7 @@ Themes and palettes live in `FlexRender.Core/Charts/` as static readonly data (A ## Testing - **Unit tests**: axis scale math (nice ticks for ranges crossing zero, negative-only, single point, identical values), palette/theme resolution, path tokenizer edge cases, data binding from context. -- **Snapshot tests**: golden images for every chart type × theme, shape primitives with gradients/strokes, canvas overlap ordering. Regenerated via `UPDATE_SNAPSHOTS=true`. +- **Snapshot tests**: golden images for every chart type × theme, shape primitives with gradients/strokes, draw overlap ordering. Regenerated via `UPDATE_SNAPSHOTS=true`. - **Validation tests**: typo suggestions for new properties, resource limit enforcement, empty-data placeholder. ## Documentation (every phase) @@ -156,7 +156,7 @@ Themes and palettes live in `FlexRender.Core/Charts/` as static readonly data (A ## Implementation Phases -1. **Shapes**: `rect`/`circle`/`ellipse` box elements, gradients, `canvas` with `line`/`polyline`/`rect`/`circle`/`path`. +1. **Shapes**: `rect`/`circle`/`ellipse` box elements, gradients, `draw` with `line`/`polyline`/`rect`/`circle`/`path`. 2. **Charts base**: `bar`/`line`/`area`/`pie`/`donut`, themes, palettes, axes, legends, "no data" placeholder. 3. **Charts extension**: `scatter`/`bubble`, `gauge`/`progress`/`sparkline`. 4. **Charts advanced**: `heatmap`/`radar`. @@ -168,5 +168,5 @@ Each phase ships independently with full tests and docs. - SVG render backend for charts (Skia only initially; `FlexRender.Svg` backend support can follow later if needed). - Animation, interactivity (static images by design). -- Arbitrary per-element style overrides beyond theme/palette (axis font sizes, custom grid styles) — themes are the styling surface; escape hatch is the `canvas` element. +- Arbitrary per-element style overrides beyond theme/palette (axis font sizes, custom grid styles) — themes are the styling surface; escape hatch is the `draw` element. - MCP server (agents use YAML + existing CLI; revisit after element work lands). From 20ea8fc123c14c6714cbb6e9a844bcf73f4380df Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Fri, 12 Jun 2026 14:01:51 +0400 Subject: [PATCH 003/119] docs: add shapes phase 1 implementation plan --- .../plans/2026-06-12-shapes-phase1.md | 3527 +++++++++++++++++ 1 file changed, 3527 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-shapes-phase1.md diff --git a/docs/superpowers/plans/2026-06-12-shapes-phase1.md b/docs/superpowers/plans/2026-06-12-shapes-phase1.md new file mode 100644 index 0000000..b5ac7a9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-shapes-phase1.md @@ -0,0 +1,3527 @@ +# Shapes (Phase 1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add declarative shape primitives — `rect`, `circle`, `ellipse` (flex box shapes with fill/gradient/stroke/opacity), and a `draw` element holding absolute-coordinate shapes (`line`, `polyline`, `rect`, `circle`, `path`) — so LLM agents can produce custom graphics without hand-written SVG. + +**Architecture:** New AST element classes in `FlexRender.Core/Parsing/Ast/` (`RectElement`, `CircleElement`, `EllipseElement`, `DrawElement`) follow the existing `SeparatorElement` pattern (override `Type`, `ResolveExpressions`, `Materialize`, `CloneWithSubstitution`). A pure, renderer-agnostic, hand-written `PathDataParser` (no regex) lives in Core. Parsing extends `FlexRender.Yaml` (`ElementParsers`, `TemplateParser` dispatch, `KnownProperties`). Layout treats shapes as leaf boxes with explicit dimensions (like `SeparatorElement`). Rendering extends `FlexRender.Skia.Render` via the existing `switch (element)` dispatch in `RenderingEngine.DrawElement`. The object-form gradient is converted to FlexRender's existing CSS-gradient string at parse time, reusing `GradientParser`. + +**Tech Stack:** .NET 10, C# latest, xUnit, SkiaSharp, YamlDotNet. AOT-safe (no reflection, no `dynamic`, no regex for path parsing), `sealed` classes, `ArgumentNullException.ThrowIfNull`, switch-based dispatch, XML docs on all public API. + +--- + +## Conventions used throughout this plan + +- All commands are run from the repo root `/Users/robonet/Projects/SkiaLayout`. +- Branch is already `feature/charts-and-shapes`. Do NOT create worktrees. Do NOT merge to `main`. +- Build: `dotnet build FlexRender.slnx`. Test: `dotnet test FlexRender.slnx`. +- NEVER pipe `dotnet` output through `tail`/`head`/`grep`. Run commands directly. +- Commit messages use Conventional Commits, no attribution/Co-Authored-By lines. +- After every code edit, the build must be warning-free (`TreatWarningsAsErrors=true`). + +## File structure (created/modified across all tasks) + +Created: +- `src/FlexRender.Core/Parsing/Ast/RectElement.cs` +- `src/FlexRender.Core/Parsing/Ast/CircleElement.cs` +- `src/FlexRender.Core/Parsing/Ast/EllipseElement.cs` +- `src/FlexRender.Core/Parsing/Ast/DrawShapes.cs` (shape DTOs for `draw`) +- `src/FlexRender.Core/Parsing/Ast/DrawElement.cs` +- `src/FlexRender.Core/Parsing/PathDataParser.cs` (pure tokenizer) +- `src/FlexRender.Yaml/Parsing/ShapeParsers.cs` (parse box shapes + draw shapes + gradient object) +- `src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs` (Skia drawing for shapes + draw) +- Test files (see each task) + +Modified: +- `src/FlexRender.Core/Parsing/Ast/TemplateElement.cs` (add `ElementType` enum members) +- `src/FlexRender.Core/Configuration/ResourceLimits.cs` (add `MaxShapesPerDraw`) +- `src/FlexRender.Core/Layout/LayoutEngine.cs` (layout the new leaf elements) +- `src/FlexRender.Core/Layout/IntrinsicMeasurer.cs` (intrinsic size for new leaf elements) +- `src/FlexRender.Yaml/Parsing/TemplateParser.cs` (register `rect`/`circle`/`ellipse`/`draw`) +- `src/FlexRender.Yaml/Parsing/KnownProperties.cs` (new property sets + registry entries) +- `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs` (dispatch new elements) +- Docs: `llms.txt`, `llms-full.txt`, `docs/wiki/Element-Reference.md`, `docs/wiki/Visual-Reference.md`, + `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`, + `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` + +--- + +## Task 1: ResourceLimits.MaxShapesPerDraw + +**Files:** +- Modify: `src/FlexRender.Core/Configuration/ResourceLimits.cs` +- Test: `tests/FlexRender.Tests/Configuration/ResourceLimitsShapesTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Configuration/ResourceLimitsShapesTests.cs`: + +```csharp +using System; +using FlexRender.Configuration; +using Xunit; + +namespace FlexRender.Tests.Configuration; + +/// +/// Tests for the limit. +/// +public sealed class ResourceLimitsShapesTests +{ + [Fact] + public void MaxShapesPerDraw_DefaultsTo1000() + { + var limits = new ResourceLimits(); + Assert.Equal(1000, limits.MaxShapesPerDraw); + } + + [Fact] + public void MaxShapesPerDraw_AcceptsPositiveValue() + { + var limits = new ResourceLimits { MaxShapesPerDraw = 50 }; + Assert.Equal(50, limits.MaxShapesPerDraw); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MaxShapesPerDraw_RejectsNonPositive(int value) + { + var limits = new ResourceLimits(); + Assert.Throws(() => limits.MaxShapesPerDraw = value); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ResourceLimitsShapesTests"` +Expected: BUILD FAILURE — `ResourceLimits` does not contain a definition for `MaxShapesPerDraw`. + +- [ ] **Step 3: Add the property** + +In `src/FlexRender.Core/Configuration/ResourceLimits.cs`, add a backing field next to the others (after `private int _maxFlexLines = 1000;`): + +```csharp + private int _maxShapesPerDraw = 1000; +``` + +Then add this property after the `MaxImageSize` property (before the closing brace of the class): + +```csharp + /// + /// Maximum number of shapes allowed in a single 'draw' element. + /// Prevents resource exhaustion from templates with a huge shape list. + /// + /// Default: 1000. + /// Thrown when value is zero or negative. + public int MaxShapesPerDraw + { + get => _maxShapesPerDraw; + set + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + _maxShapesPerDraw = value; + } + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ResourceLimitsShapesTests"` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Configuration/ResourceLimits.cs tests/FlexRender.Tests/Configuration/ResourceLimitsShapesTests.cs +git commit -m "feat(core): add MaxShapesPerDraw resource limit" +``` + +--- + +## Task 2: ElementType enum members + +**Files:** +- Modify: `src/FlexRender.Core/Parsing/Ast/TemplateElement.cs` + +- [ ] **Step 1: Add the new enum members** + +In `src/FlexRender.Core/Parsing/Ast/TemplateElement.cs`, inside the `ElementType` enum, add these members after the `Content` member (insert a comma after `Content` first): + +```csharp + /// + /// A rectangle shape element (flex box drawn as a filled/stroked rect). + /// + Rect, + + /// + /// A circle shape element (flex box drawn as a filled/stroked circle). + /// + Circle, + + /// + /// An ellipse shape element (flex box drawn as a filled/stroked ellipse). + /// + Ellipse, + + /// + /// A free-form drawing element holding absolute-coordinate shapes. + /// + Draw +``` + +The enum tail must read: + +```csharp + /// + /// A content element that expands formatted text into a subtree. + /// + Content, + + /// + /// A rectangle shape element (flex box drawn as a filled/stroked rect). + /// + Rect, + // ... (Circle, Ellipse, Draw as above) +``` + +- [ ] **Step 2: Verify the build compiles** + +Run: `dotnet build FlexRender.slnx` +Expected: BUILD SUCCEEDED (no behavior change yet; this only extends the enum). + +- [ ] **Step 3: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Ast/TemplateElement.cs +git commit -m "feat(ast): add Rect, Circle, Ellipse, Draw element types" +``` + +--- + +## Task 3: PathDataParser (hand-written tokenizer, no regex) + +This is the highest-risk component. Build it pure (Core, renderer-agnostic) with thorough edge-case tests before wiring anything into Skia. + +**Files:** +- Create: `src/FlexRender.Core/Parsing/PathDataParser.cs` +- Test: `tests/FlexRender.Tests/Parsing/PathDataParserTests.cs` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/FlexRender.Tests/Parsing/PathDataParserTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Parsing; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Edge-case tests for the hand-written absolute-only SVG-style path tokenizer. +/// +public sealed class PathDataParserTests +{ + [Fact] + public void Parse_MoveAndLine_ProducesTwoCommands() + { + var commands = PathDataParser.Parse("M 0 0 L 100 50"); + + Assert.Equal(2, commands.Count); + Assert.Equal(PathCommandKind.MoveTo, commands[0].Kind); + Assert.Equal(0f, commands[0].Points[0].X); + Assert.Equal(0f, commands[0].Points[0].Y); + Assert.Equal(PathCommandKind.LineTo, commands[1].Kind); + Assert.Equal(100f, commands[1].Points[0].X); + Assert.Equal(50f, commands[1].Points[0].Y); + } + + [Fact] + public void Parse_QuadraticAndCubicAndClose_ProducesAllCommands() + { + var commands = PathDataParser.Parse("M 0 0 Q 150 0 200 50 C 10 20 30 40 50 60 Z"); + + Assert.Equal(4, commands.Count); + Assert.Equal(PathCommandKind.MoveTo, commands[0].Kind); + Assert.Equal(PathCommandKind.QuadTo, commands[1].Kind); + Assert.Equal(2, commands[1].Points.Count); + Assert.Equal(PathCommandKind.CubicTo, commands[2].Kind); + Assert.Equal(3, commands[2].Points.Count); + Assert.Equal(PathCommandKind.Close, commands[3].Kind); + Assert.Empty(commands[3].Points); + } + + [Fact] + public void Parse_CommaSeparatedCoordinates_ParsesCorrectly() + { + var commands = PathDataParser.Parse("M0,0 L100,50"); + + Assert.Equal(2, commands.Count); + Assert.Equal(100f, commands[1].Points[0].X); + Assert.Equal(50f, commands[1].Points[0].Y); + } + + [Fact] + public void Parse_NegativeAndDecimalCoordinates_ParsesCorrectly() + { + var commands = PathDataParser.Parse("M -1.5 -2.25 L 3.0 -4"); + + Assert.Equal(-1.5f, commands[0].Points[0].X); + Assert.Equal(-2.25f, commands[0].Points[0].Y); + Assert.Equal(3.0f, commands[1].Points[0].X); + Assert.Equal(-4f, commands[1].Points[0].Y); + } + + [Fact] + public void Parse_ImplicitRepeatedLineTo_AfterSingleCommandLetter() + { + // SVG semantics: "L 10 10 20 20" means two LineTo commands. + var commands = PathDataParser.Parse("M 0 0 L 10 10 20 20"); + + Assert.Equal(3, commands.Count); + Assert.Equal(PathCommandKind.LineTo, commands[1].Kind); + Assert.Equal(10f, commands[1].Points[0].X); + Assert.Equal(PathCommandKind.LineTo, commands[2].Kind); + Assert.Equal(20f, commands[2].Points[0].X); + Assert.Equal(20f, commands[2].Points[0].Y); + } + + [Fact] + public void Parse_LowercaseCommands_TreatedAsAbsolute() + { + // Lowercase (relative) letters are accepted but treated as absolute, + // matching the spec's "absolute only" constraint without erroring on case. + var commands = PathDataParser.Parse("m 0 0 l 100 50"); + + Assert.Equal(PathCommandKind.MoveTo, commands[0].Kind); + Assert.Equal(PathCommandKind.LineTo, commands[1].Kind); + Assert.Equal(100f, commands[1].Points[0].X); + } + + [Fact] + public void Parse_ExtraWhitespace_Ignored() + { + var commands = PathDataParser.Parse(" M 0 0\tL\n100 50 "); + Assert.Equal(2, commands.Count); + } + + [Fact] + public void Parse_EmptyString_ReturnsEmptyList() + { + Assert.Empty(PathDataParser.Parse("")); + Assert.Empty(PathDataParser.Parse(" ")); + } + + [Fact] + public void Parse_UnknownCommand_ThrowsWithCommandAndPosition() + { + var ex = Assert.Throws(() => PathDataParser.Parse("M 0 0 X 1 1")); + Assert.Contains("'X'", ex.Message); + Assert.Contains("position", ex.Message); + } + + [Fact] + public void Parse_MissingCoordinate_ThrowsWithCommand() + { + var ex = Assert.Throws(() => PathDataParser.Parse("M 0 0 L 10")); + Assert.Contains("'L'", ex.Message); + } + + [Fact] + public void Parse_DataBeforeFirstCommand_Throws() + { + var ex = Assert.Throws(() => PathDataParser.Parse("10 20 L 30 40")); + Assert.Contains("position", ex.Message); + } + + [Fact] + public void Parse_NullArgument_Throws() + { + Assert.Throws(() => PathDataParser.Parse(null!)); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~PathDataParserTests"` +Expected: BUILD FAILURE — `PathDataParser`, `PathCommandKind`, `PathParseException` not defined. + +- [ ] **Step 3: Implement PathDataParser** + +Create `src/FlexRender.Core/Parsing/PathDataParser.cs`: + +```csharp +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace FlexRender.Parsing; + +/// +/// The kind of a parsed path command. +/// +public enum PathCommandKind +{ + /// Move the pen to a point (M). + MoveTo, + + /// Draw a straight line to a point (L). + LineTo, + + /// Draw a quadratic Bézier curve (Q): one control point, one end point. + QuadTo, + + /// Draw a cubic Bézier curve (C): two control points, one end point. + CubicTo, + + /// Close the current sub-path (Z). + Close +} + +/// +/// A 2D point in absolute path coordinates. +/// +/// The X coordinate. +/// The Y coordinate. +public readonly record struct PathPoint(float X, float Y); + +/// +/// A single parsed path command with its associated points. +/// +/// The command kind. +/// The command's points (empty for ). +public sealed record PathCommand(PathCommandKind Kind, IReadOnlyList Points); + +/// +/// Thrown when path data ('d' attribute) cannot be parsed. +/// +public sealed class PathParseException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message. + public PathParseException(string message) : base(message) + { + } +} + +/// +/// Hand-written tokenizer for SVG-style path data restricted to absolute commands +/// M, L, Q, C, Z. AOT-safe — no regex, no backtracking. +/// +/// +/// Lowercase command letters are accepted but treated as absolute (the spec restricts +/// drawing to absolute coordinates). Numbers may be separated by whitespace and/or commas. +/// Implicit repeated commands are supported per SVG semantics (e.g. "L 1 1 2 2" is two +/// line-to commands). +/// +public static class PathDataParser +{ + /// + /// Parses path data into an ordered list of absolute commands. + /// + /// The path data string (e.g. "M 0 0 L 100 50 Z"). + /// The parsed commands in order. Empty when is blank. + /// Thrown when is null. + /// Thrown on malformed input, naming the command and position. + public static IReadOnlyList Parse(string data) + { + ArgumentNullException.ThrowIfNull(data); + + var commands = new List(); + var i = 0; + var length = data.Length; + char currentCommand = '\0'; + + while (i < length) + { + SkipSeparators(data, ref i); + if (i >= length) + break; + + var c = data[i]; + var upper = char.ToUpperInvariant(c); + + if (upper is 'M' or 'L' or 'Q' or 'C' or 'Z') + { + currentCommand = upper; + i++; + + if (currentCommand == 'Z') + { + commands.Add(new PathCommand(PathCommandKind.Close, Array.Empty())); + currentCommand = '\0'; + } + continue; + } + + // Not a command letter: must be a coordinate continuing the current command. + if (currentCommand == '\0') + { + throw new PathParseException( + $"Unexpected character '{c}' at position {i}: path data must begin with a command letter (M, L, Q, C, Z)."); + } + + if (!IsCoordinateStart(c)) + { + throw new PathParseException( + $"Unexpected character '{c}' at position {i} while reading command '{currentCommand}'."); + } + + var (kind, pointCount) = currentCommand switch + { + 'M' => (PathCommandKind.MoveTo, 1), + 'L' => (PathCommandKind.LineTo, 1), + 'Q' => (PathCommandKind.QuadTo, 2), + 'C' => (PathCommandKind.CubicTo, 3), + _ => throw new PathParseException( + $"Internal error: unexpected command '{currentCommand}' at position {i}.") + }; + + var points = new PathPoint[pointCount]; + for (var p = 0; p < pointCount; p++) + { + var x = ReadNumber(data, ref i, currentCommand); + var y = ReadNumber(data, ref i, currentCommand); + points[p] = new PathPoint(x, y); + } + + commands.Add(new PathCommand(kind, points)); + + // After an initial MoveTo, repeated coordinates imply LineTo (SVG semantics). + if (currentCommand == 'M') + { + currentCommand = 'L'; + } + } + + return commands; + } + + private static bool IsCoordinateStart(char c) + => c is '-' or '+' or '.' || (c >= '0' && c <= '9'); + + private static void SkipSeparators(string data, ref int i) + { + while (i < data.Length) + { + var c = data[i]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == ',') + { + i++; + continue; + } + break; + } + } + + private static float ReadNumber(string data, ref int i, char command) + { + SkipSeparators(data, ref i); + + var start = i; + var length = data.Length; + + if (i < length && (data[i] == '-' || data[i] == '+')) + i++; + + var hasDigits = false; + while (i < length && data[i] >= '0' && data[i] <= '9') + { + i++; + hasDigits = true; + } + + if (i < length && data[i] == '.') + { + i++; + while (i < length && data[i] >= '0' && data[i] <= '9') + { + i++; + hasDigits = true; + } + } + + // Exponent (e.g. 1e3, 2.5E-2) + if (i < length && (data[i] == 'e' || data[i] == 'E')) + { + i++; + if (i < length && (data[i] == '-' || data[i] == '+')) + i++; + while (i < length && data[i] >= '0' && data[i] <= '9') + i++; + } + + if (!hasDigits) + { + throw new PathParseException( + $"Expected a number at position {start} while reading command '{command}'."); + } + + var span = data.AsSpan(start, i - start); + if (!float.TryParse(span, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) + { + throw new PathParseException( + $"Invalid number '{span.ToString()}' at position {start} while reading command '{command}'."); + } + + return value; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~PathDataParserTests"` +Expected: PASS (12 test cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Parsing/PathDataParser.cs tests/FlexRender.Tests/Parsing/PathDataParserTests.cs +git commit -m "feat(parser): add hand-written absolute-only path data tokenizer" +``` + +--- + +## Task 4: RectElement AST class + +**Files:** +- Create: `src/FlexRender.Core/Parsing/Ast/RectElement.cs` +- Test: `tests/FlexRender.Tests/Parsing/Ast/RectElementTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Ast/RectElementTests.cs`: + +```csharp +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the AST class. +/// +public sealed class RectElementTests +{ + [Fact] + public void Type_IsRect() + { + var rect = new RectElement(); + Assert.Equal(ElementType.Rect, rect.Type); + } + + [Fact] + public void Defaults_AreEmpty() + { + var rect = new RectElement(); + Assert.Null(rect.Fill.Value); + Assert.Null(rect.Stroke.Value); + Assert.Equal(0f, rect.StrokeWidth.Value); + Assert.Null(rect.Radius.Value); + } + + [Fact] + public void CloneWithSubstitution_CopiesShapeProperties() + { + var rect = new RectElement + { + Fill = "#4A90D9", + Stroke = "#333333", + StrokeWidth = 2f, + Radius = "4", + Width = "100", + Height = "50" + }; + + var clone = (RectElement)rect.CloneWithSubstitution(s => s); + + Assert.Equal("#4A90D9", clone.Fill.Value); + Assert.Equal("#333333", clone.Stroke.Value); + Assert.Equal(2f, clone.StrokeWidth.Value); + Assert.Equal("4", clone.Radius.Value); + Assert.Equal("100", clone.Width.Value); + Assert.Equal("50", clone.Height.Value); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~RectElementTests"` +Expected: BUILD FAILURE — `RectElement` not defined. + +- [ ] **Step 3: Implement RectElement** + +Create `src/FlexRender.Core/Parsing/Ast/RectElement.cs`: + +```csharp +using System; +using FlexRender.TemplateEngine; + +namespace FlexRender.Parsing.Ast; + +/// +/// A rectangle shape element. Participates in flex layout as a box with explicit +/// width/height and is drawn as a filled and/or stroked rectangle with optional rounded corners. +/// +public sealed class RectElement : TemplateElement +{ + /// + public override ElementType Type => ElementType.Rect; + + /// + /// Fill: a solid color (e.g. "#4A90D9") or a gradient string produced from the YAML + /// gradient object form (a CSS-style "linear-gradient(...)" / "radial-gradient(...)"). + /// Null means no fill. + /// + public ExprValue Fill { get; set; } + + /// Stroke color in hex format. Null means no stroke. + public ExprValue Stroke { get; set; } + + /// Stroke width in pixels. Zero means no stroke. + public ExprValue StrokeWidth { get; set; } + + /// Corner radius (px, em). Null means square corners. + public ExprValue Radius { get; set; } + + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new RectElement + { + Fill = substitutor(Fill.Value)!, + Stroke = Stroke, + StrokeWidth = StrokeWidth, + Radius = Radius + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + + /// + public override void ResolveExpressions(Func resolver, ObjectValue data) + { + base.ResolveExpressions(resolver, data); + Fill = Fill.Resolve(resolver, data); + Stroke = Stroke.Resolve(resolver, data); + StrokeWidth = StrokeWidth.Resolve(resolver, data); + Radius = Radius.Resolve(resolver, data); + } + + /// + public override void Materialize() + { + base.Materialize(); + Fill = Fill.Materialize(nameof(Fill)); + Stroke = Stroke.Materialize(nameof(Stroke), ValueKind.Color); + StrokeWidth = StrokeWidth.Materialize(nameof(StrokeWidth)); + Radius = Radius.Materialize(nameof(Radius), ValueKind.Size); + } +} +``` + +Note: `Fill` is materialized with `ValueKind.Any` (not `Color`) because it may hold a gradient string. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~RectElementTests"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Ast/RectElement.cs tests/FlexRender.Tests/Parsing/Ast/RectElementTests.cs +git commit -m "feat(ast): add RectElement shape" +``` + +--- + +## Task 5: CircleElement AST class + +**Files:** +- Create: `src/FlexRender.Core/Parsing/Ast/CircleElement.cs` +- Test: `tests/FlexRender.Tests/Parsing/Ast/CircleElementTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Ast/CircleElementTests.cs`: + +```csharp +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the AST class. +/// +public sealed class CircleElementTests +{ + [Fact] + public void Type_IsCircle() + { + var circle = new CircleElement(); + Assert.Equal(ElementType.Circle, circle.Type); + } + + [Fact] + public void CloneWithSubstitution_CopiesShapeProperties() + { + var circle = new CircleElement + { + Fill = "#e74c3c", + Stroke = "#000000", + StrokeWidth = 1.5f, + Width = "40", + Height = "40" + }; + + var clone = (CircleElement)circle.CloneWithSubstitution(s => s); + + Assert.Equal("#e74c3c", clone.Fill.Value); + Assert.Equal("#000000", clone.Stroke.Value); + Assert.Equal(1.5f, clone.StrokeWidth.Value); + Assert.Equal("40", clone.Width.Value); + Assert.Equal("40", clone.Height.Value); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~CircleElementTests"` +Expected: BUILD FAILURE — `CircleElement` not defined. + +- [ ] **Step 3: Implement CircleElement** + +Create `src/FlexRender.Core/Parsing/Ast/CircleElement.cs`: + +```csharp +using System; +using FlexRender.TemplateEngine; + +namespace FlexRender.Parsing.Ast; + +/// +/// A circle shape element. Participates in flex layout as a square box (the YAML 'size' +/// shorthand sets both Width and Height) and is drawn as a filled and/or stroked circle +/// inscribed in the box. When Width and Height differ, the smaller dimension is used as the diameter. +/// +public sealed class CircleElement : TemplateElement +{ + /// + public override ElementType Type => ElementType.Circle; + + /// + /// Fill: a solid color (e.g. "#e74c3c") or a gradient string produced from the YAML + /// gradient object form. Null means no fill. + /// + public ExprValue Fill { get; set; } + + /// Stroke color in hex format. Null means no stroke. + public ExprValue Stroke { get; set; } + + /// Stroke width in pixels. Zero means no stroke. + public ExprValue StrokeWidth { get; set; } + + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new CircleElement + { + Fill = substitutor(Fill.Value)!, + Stroke = Stroke, + StrokeWidth = StrokeWidth + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + + /// + public override void ResolveExpressions(Func resolver, ObjectValue data) + { + base.ResolveExpressions(resolver, data); + Fill = Fill.Resolve(resolver, data); + Stroke = Stroke.Resolve(resolver, data); + StrokeWidth = StrokeWidth.Resolve(resolver, data); + } + + /// + public override void Materialize() + { + base.Materialize(); + Fill = Fill.Materialize(nameof(Fill)); + Stroke = Stroke.Materialize(nameof(Stroke), ValueKind.Color); + StrokeWidth = StrokeWidth.Materialize(nameof(StrokeWidth)); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~CircleElementTests"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Ast/CircleElement.cs tests/FlexRender.Tests/Parsing/Ast/CircleElementTests.cs +git commit -m "feat(ast): add CircleElement shape" +``` + +--- + +## Task 6: EllipseElement AST class + +**Files:** +- Create: `src/FlexRender.Core/Parsing/Ast/EllipseElement.cs` +- Test: `tests/FlexRender.Tests/Parsing/Ast/EllipseElementTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Ast/EllipseElementTests.cs`: + +```csharp +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the AST class. +/// +public sealed class EllipseElementTests +{ + [Fact] + public void Type_IsEllipse() + { + var ellipse = new EllipseElement(); + Assert.Equal(ElementType.Ellipse, ellipse.Type); + } + + [Fact] + public void CloneWithSubstitution_CopiesShapeProperties() + { + var ellipse = new EllipseElement + { + Fill = "#2ecc71", + Stroke = "#111111", + StrokeWidth = 3f, + Width = "120", + Height = "60" + }; + + var clone = (EllipseElement)ellipse.CloneWithSubstitution(s => s); + + Assert.Equal("#2ecc71", clone.Fill.Value); + Assert.Equal("#111111", clone.Stroke.Value); + Assert.Equal(3f, clone.StrokeWidth.Value); + Assert.Equal("120", clone.Width.Value); + Assert.Equal("60", clone.Height.Value); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~EllipseElementTests"` +Expected: BUILD FAILURE — `EllipseElement` not defined. + +- [ ] **Step 3: Implement EllipseElement** + +Create `src/FlexRender.Core/Parsing/Ast/EllipseElement.cs`: + +```csharp +using System; +using FlexRender.TemplateEngine; + +namespace FlexRender.Parsing.Ast; + +/// +/// An ellipse shape element. Participates in flex layout as a box with explicit +/// width/height and is drawn as a filled and/or stroked ellipse inscribed in the box. +/// +public sealed class EllipseElement : TemplateElement +{ + /// + public override ElementType Type => ElementType.Ellipse; + + /// + /// Fill: a solid color or a gradient string produced from the YAML gradient object form. + /// Null means no fill. + /// + public ExprValue Fill { get; set; } + + /// Stroke color in hex format. Null means no stroke. + public ExprValue Stroke { get; set; } + + /// Stroke width in pixels. Zero means no stroke. + public ExprValue StrokeWidth { get; set; } + + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new EllipseElement + { + Fill = substitutor(Fill.Value)!, + Stroke = Stroke, + StrokeWidth = StrokeWidth + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + + /// + public override void ResolveExpressions(Func resolver, ObjectValue data) + { + base.ResolveExpressions(resolver, data); + Fill = Fill.Resolve(resolver, data); + Stroke = Stroke.Resolve(resolver, data); + StrokeWidth = StrokeWidth.Resolve(resolver, data); + } + + /// + public override void Materialize() + { + base.Materialize(); + Fill = Fill.Materialize(nameof(Fill)); + Stroke = Stroke.Materialize(nameof(Stroke), ValueKind.Color); + StrokeWidth = StrokeWidth.Materialize(nameof(StrokeWidth)); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~EllipseElementTests"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Ast/EllipseElement.cs tests/FlexRender.Tests/Parsing/Ast/EllipseElementTests.cs +git commit -m "feat(ast): add EllipseElement shape" +``` + +--- + +## Task 7: DrawShapes DTOs + +The `draw` element holds a list of absolute-coordinate shapes. These are renderer-agnostic +immutable DTOs in Core. The pre-parsed path commands are stored on the `DrawPath` DTO so the +renderer never re-parses. + +**Files:** +- Create: `src/FlexRender.Core/Parsing/Ast/DrawShapes.cs` +- Test: `tests/FlexRender.Tests/Parsing/Ast/DrawShapesTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Ast/DrawShapesTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the immutable draw-shape DTOs. +/// +public sealed class DrawShapesTests +{ + [Fact] + public void DrawLine_StoresCoordinatesAndStroke() + { + var line = new DrawLine(0f, 100f, 400f, 50f, "#333333", 2f); + Assert.Equal(0f, line.X1); + Assert.Equal(100f, line.Y1); + Assert.Equal(400f, line.X2); + Assert.Equal(50f, line.Y2); + Assert.Equal("#333333", line.Stroke); + Assert.Equal(2f, line.StrokeWidth); + } + + [Fact] + public void DrawPolyline_StoresPointsAndStroke() + { + var points = new List { new(0f, 10f), new(50f, 40f) }; + var polyline = new DrawPolyline(points, "#4A90D9", 1f, fill: null); + Assert.Equal(2, polyline.Points.Count); + Assert.Equal("#4A90D9", polyline.Stroke); + } + + [Fact] + public void DrawRect_StoresGeometryFillStrokeRadius() + { + var rect = new DrawRect(10f, 10f, 80f, 40f, "#eeeeee", stroke: null, strokeWidth: 0f, radius: 4f); + Assert.Equal(10f, rect.X); + Assert.Equal(80f, rect.Width); + Assert.Equal("#eeeeee", rect.Fill); + Assert.Equal(4f, rect.Radius); + } + + [Fact] + public void DrawCircle_StoresCenterRadiusFill() + { + var circle = new DrawCircle(200f, 75f, 30f, "#e74c3c", stroke: null, strokeWidth: 0f); + Assert.Equal(200f, circle.Cx); + Assert.Equal(75f, circle.Cy); + Assert.Equal(30f, circle.R); + Assert.Equal("#e74c3c", circle.Fill); + } + + [Fact] + public void DrawPath_StoresCommandsFillStroke() + { + var commands = PathDataParser.Parse("M 0 0 L 100 50 Z"); + var path = new DrawPath(commands, "#2ecc71", stroke: null, strokeWidth: 0f); + Assert.Equal(3, path.Commands.Count); + Assert.Equal("#2ecc71", path.Fill); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawShapesTests"` +Expected: BUILD FAILURE — `DrawLine`, `DrawPolyline`, `DrawRect`, `DrawCircle`, `DrawPath`, `DrawShape` not defined. + +- [ ] **Step 3: Implement DrawShapes** + +Create `src/FlexRender.Core/Parsing/Ast/DrawShapes.cs`: + +```csharp +using System.Collections.Generic; + +namespace FlexRender.Parsing.Ast; + +/// +/// Base type for a single shape inside a . +/// All coordinates are absolute, relative to the draw element's top-left corner. +/// +public abstract record DrawShape; + +/// +/// A straight line segment. +/// +/// Start X. +/// Start Y. +/// End X. +/// End Y. +/// Stroke color (hex). Null means default black. +/// Stroke width in pixels. +public sealed record DrawLine( + float X1, float Y1, float X2, float Y2, string? Stroke, float StrokeWidth) : DrawShape; + +/// +/// A connected sequence of line segments through the given points. +/// +/// The vertices in order. +/// Stroke color (hex). Null means default black. +/// Stroke width in pixels. +/// Optional fill color (hex) for the enclosed area. Null means no fill. +public sealed record DrawPolyline( + IReadOnlyList Points, string? Stroke, float StrokeWidth, string? Fill) : DrawShape; + +/// +/// A rectangle, optionally rounded. +/// +/// Top-left X. +/// Top-left Y. +/// Width in pixels. +/// Height in pixels. +/// Optional fill color (hex). Null means no fill. +/// Optional stroke color (hex). Null means no stroke. +/// Stroke width in pixels. +/// Corner radius in pixels. +public sealed record DrawRect( + float X, float Y, float Width, float Height, + string? Fill, string? Stroke, float StrokeWidth, float Radius) : DrawShape; + +/// +/// A circle centred at (Cx, Cy) with radius R. +/// +/// Centre X. +/// Centre Y. +/// Radius in pixels. +/// Optional fill color (hex). Null means no fill. +/// Optional stroke color (hex). Null means no stroke. +/// Stroke width in pixels. +public sealed record DrawCircle( + float Cx, float Cy, float R, + string? Fill, string? Stroke, float StrokeWidth) : DrawShape; + +/// +/// A free-form path made of pre-parsed absolute commands. +/// +/// The parsed path commands (M/L/Q/C/Z). +/// Optional fill color (hex). Null means no fill. +/// Optional stroke color (hex). Null means no stroke. +/// Stroke width in pixels. +public sealed record DrawPath( + IReadOnlyList Commands, + string? Fill, string? Stroke, float StrokeWidth) : DrawShape; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawShapesTests"` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Ast/DrawShapes.cs tests/FlexRender.Tests/Parsing/Ast/DrawShapesTests.cs +git commit -m "feat(ast): add draw shape DTOs" +``` + +--- + +## Task 8: DrawElement AST class + +**Files:** +- Create: `src/FlexRender.Core/Parsing/Ast/DrawElement.cs` +- Test: `tests/FlexRender.Tests/Parsing/Ast/DrawElementTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Ast/DrawElementTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the AST class. +/// +public sealed class DrawElementTests +{ + [Fact] + public void Type_IsDraw() + { + var draw = new DrawElement(new List()); + Assert.Equal(ElementType.Draw, draw.Type); + } + + [Fact] + public void Shapes_AreExposedInOrder() + { + var shapes = new List + { + new DrawLine(0f, 0f, 10f, 10f, "#000000", 1f), + new DrawCircle(5f, 5f, 3f, "#ff0000", null, 0f) + }; + var draw = new DrawElement(shapes) { Width = "400", Height = "200" }; + + Assert.Equal(2, draw.Shapes.Count); + Assert.IsType(draw.Shapes[0]); + Assert.IsType(draw.Shapes[1]); + Assert.Equal("400", draw.Width.Value); + } + + [Fact] + public void CloneWithSubstitution_PreservesShapes() + { + var shapes = new List { new DrawLine(0f, 0f, 10f, 10f, "#000000", 1f) }; + var draw = new DrawElement(shapes) { Width = "400", Height = "200" }; + + var clone = (DrawElement)draw.CloneWithSubstitution(s => s); + + Assert.Single(clone.Shapes); + Assert.Equal("400", clone.Width.Value); + Assert.Equal("200", clone.Height.Value); + } + + [Fact] + public void Constructor_NullShapes_Throws() + { + Assert.Throws(() => new DrawElement(null!)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawElementTests"` +Expected: BUILD FAILURE — `DrawElement` not defined. + +- [ ] **Step 3: Implement DrawElement** + +Create `src/FlexRender.Core/Parsing/Ast/DrawElement.cs`: + +```csharp +using System; +using System.Collections.Generic; +using FlexRender.TemplateEngine; + +namespace FlexRender.Parsing.Ast; + +/// +/// A free-form drawing element. Participates in flex layout as a box with explicit +/// width/height; inside, an ordered list of absolute-coordinate shapes is painted +/// in list order (painter's algorithm). +/// +/// +/// Shapes use absolute coordinates relative to the element's top-left corner. +/// The shape list is fixed at parse time and is not expression-resolvable. +/// +public sealed class DrawElement : TemplateElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The ordered shapes to paint. + /// Thrown when is null. + public DrawElement(IReadOnlyList shapes) + { + ArgumentNullException.ThrowIfNull(shapes); + Shapes = shapes; + } + + /// + public override ElementType Type => ElementType.Draw; + + /// + /// The ordered list of shapes painted inside this element. + /// + public IReadOnlyList Shapes { get; } + + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new DrawElement(Shapes); + CopyBasePropertiesTo(clone, substitutor); + return clone; + } +} +``` + +Note: `DrawElement` does not override `ResolveExpressions`/`Materialize` because it has no +`ExprValue` properties of its own beyond the base; the base implementations suffice. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawElementTests"` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Ast/DrawElement.cs tests/FlexRender.Tests/Parsing/Ast/DrawElementTests.cs +git commit -m "feat(ast): add DrawElement" +``` + +--- + +## Task 9: Layout for shape leaf elements (intrinsic + layout) + +Shapes are leaf boxes like `SeparatorElement`. They need intrinsic measurement and layout entries. + +**Files:** +- Modify: `src/FlexRender.Core/Layout/IntrinsicMeasurer.cs` +- Modify: `src/FlexRender.Core/Layout/LayoutEngine.cs` +- Test: `tests/FlexRender.Tests/Layout/ShapeLayoutTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Layout/ShapeLayoutTests.cs`: + +```csharp +using FlexRender.Configuration; +using FlexRender.Layout; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Layout; + +/// +/// Layout tests for the shape leaf elements (rect, circle, ellipse, draw). +/// +public sealed class ShapeLayoutTests +{ + private static LayoutEngine CreateEngine() => new(new ResourceLimits()); + + [Fact] + public void Rect_WithExplicitSize_ProducesThatSize() + { + var rect = new RectElement { Width = "100", Height = "50", Fill = "#ff0000" }; + var template = new Template + { + Canvas = new CanvasSettings { Width = 300, Fixed = FixedDimension.Width } + }; + template.AddElement(rect); + + var engine = CreateEngine(); + var root = engine.ComputeLayout(template); + var node = root.Children[0]; + + Assert.Equal(100f, node.Width); + Assert.Equal(50f, node.Height); + } + + [Fact] + public void Draw_WithExplicitSize_ProducesThatSize() + { + var draw = new DrawElement(new[] { (DrawShape)new DrawLine(0f, 0f, 10f, 10f, "#000", 1f) }) + { + Width = "400", + Height = "200" + }; + var template = new Template + { + Canvas = new CanvasSettings { Width = 400, Fixed = FixedDimension.Width } + }; + template.AddElement(draw); + + var engine = CreateEngine(); + var root = engine.ComputeLayout(template); + var node = root.Children[0]; + + Assert.Equal(400f, node.Width); + Assert.Equal(200f, node.Height); + } +} +``` + +Note: Verify the exact entrypoint name (`ComputeLayout(Template)` returning a root `LayoutNode`) +against `LayoutEngine`; if the public method differs, mirror the call used by existing layout +tests in `tests/FlexRender.Tests/Layout/`. (The two-pass engine wires intrinsics internally for +the `Template` overload.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeLayoutTests"` +Expected: FAIL — shapes fall through to the default leaf branch and produce `ContainerWidth`/default height instead of explicit dimensions. + +- [ ] **Step 3a: Add intrinsic measurement** + +In `src/FlexRender.Core/Layout/IntrinsicMeasurer.cs`, extend the dispatch switch in +`MeasureIntrinsic` (currently mapping `SeparatorElement` etc.). Add these cases before the +`FlexElement flex =>` case: + +```csharp + RectElement rect => MeasureBoxShapeIntrinsic(rect, rect.Width.Value, rect.Height.Value), + CircleElement circle => MeasureBoxShapeIntrinsic(circle, circle.Width.Value, circle.Height.Value), + EllipseElement ellipse => MeasureBoxShapeIntrinsic(ellipse, ellipse.Width.Value, ellipse.Height.Value), + DrawElement draw => MeasureBoxShapeIntrinsic(draw, draw.Width.Value, draw.Height.Value), +``` + +Then add this helper method (place it after `MeasureSeparatorIntrinsic`): + +```csharp + /// + /// Measures intrinsic size for a leaf shape element (rect, circle, ellipse, draw) + /// using its explicit width/height. Defaults to zero when a dimension is unspecified. + /// + private static IntrinsicSize MeasureBoxShapeIntrinsic(TemplateElement element, string? width, string? height) + { + var w = ParseAbsolutePixelValue(width, 0f); + var h = ParseAbsolutePixelValue(height, 0f); + var intrinsic = new IntrinsicSize(w, w, h, h); + return ApplyPaddingBorderAndMargin(intrinsic, element); + } +``` + +- [ ] **Step 3b: Add layout entries** + +In `src/FlexRender.Core/Layout/LayoutEngine.cs`, in `LayoutElement`'s `element switch`, add these +cases before the `SeparatorElement separator =>` line: + +```csharp + RectElement rect => LayoutBoxShapeElement(rect, rect.Width.Value, rect.Height.Value, context), + CircleElement circle => LayoutBoxShapeElement(circle, circle.Width.Value, circle.Height.Value, context), + EllipseElement ellipse => LayoutBoxShapeElement(ellipse, ellipse.Width.Value, ellipse.Height.Value, context), + DrawElement draw => LayoutBoxShapeElement(draw, draw.Width.Value, draw.Height.Value, context), +``` + +Then add this method right after `LayoutSeparatorElement`: + +```csharp + /// + /// Lays out a leaf shape element (rect, circle, ellipse, draw) using its explicit + /// width/height plus padding and border. Width defaults to the container width and + /// height to zero when unspecified. + /// + private static LayoutNode LayoutBoxShapeElement(TemplateElement element, string? width, string? height, LayoutContext context) + { + var padding = PaddingParser.Parse(element.Padding.Value, context.ContainerWidth, context.FontSize).ClampNegatives(); + var border = BorderParser.Resolve(element, context.ContainerWidth, context.FontSize); + + var contentWidth = context.ResolveWidth(width) ?? context.ContainerWidth; + var contentHeight = context.ResolveHeight(height) ?? 0f; + + var totalWidth = contentWidth + padding.Horizontal + border.Horizontal; + var totalHeight = contentHeight + padding.Vertical + border.Vertical; + + return new LayoutNode(element, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize }; + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeLayoutTests"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Layout/IntrinsicMeasurer.cs src/FlexRender.Core/Layout/LayoutEngine.cs tests/FlexRender.Tests/Layout/ShapeLayoutTests.cs +git commit -m "feat(layout): measure and lay out shape leaf elements" +``` + +--- + +## Task 10: Gradient object form -> CSS gradient string converter + +The YAML `fill` object form is converted to the existing CSS gradient string so the renderer can +reuse `GradientParser`. This converter lives in `FlexRender.Yaml` as a static helper. + +**Files:** +- Create: `src/FlexRender.Yaml/Parsing/ShapeParsers.cs` (start with the gradient converter only) +- Test: `tests/FlexRender.Tests/Parsing/GradientObjectParseTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/GradientObjectParseTests.cs`: + +```csharp +using System.IO; +using FlexRender.Parsing; +using YamlDotNet.RepresentationModel; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for converting the YAML gradient object form to a CSS gradient string. +/// +public sealed class GradientObjectParseTests +{ + private static YamlMappingNode ParseMapping(string yaml) + { + var stream = new YamlStream(); + using var reader = new StringReader(yaml); + stream.Load(reader); + return (YamlMappingNode)stream.Documents[0].RootNode; + } + + [Fact] + public void LinearGradient_WithAngleAndColors_ProducesCssString() + { + var node = ParseMapping(""" + gradient: linear + colors: ["#ff0000", "#0000ff"] + angle: 45 + """); + + var css = ShapeParsers.ConvertGradientObjectToCss(node); + + Assert.Equal("linear-gradient(45deg, #ff0000, #0000ff)", css); + } + + [Fact] + public void LinearGradient_WithoutAngle_DefaultsToZeroDeg() + { + var node = ParseMapping(""" + gradient: linear + colors: ["#fff", "#000"] + """); + + var css = ShapeParsers.ConvertGradientObjectToCss(node); + + Assert.Equal("linear-gradient(0deg, #fff, #000)", css); + } + + [Fact] + public void RadialGradient_IgnoresAngle() + { + var node = ParseMapping(""" + gradient: radial + colors: ["#fff", "#000"] + angle: 90 + """); + + var css = ShapeParsers.ConvertGradientObjectToCss(node); + + Assert.Equal("radial-gradient(#fff, #000)", css); + } + + [Fact] + public void Gradient_WithFewerThanTwoColors_Throws() + { + var node = ParseMapping(""" + gradient: linear + colors: ["#fff"] + """); + + Assert.Throws(() => ShapeParsers.ConvertGradientObjectToCss(node)); + } + + [Fact] + public void Gradient_WithUnknownType_Throws() + { + var node = ParseMapping(""" + gradient: conic + colors: ["#fff", "#000"] + """); + + Assert.Throws(() => ShapeParsers.ConvertGradientObjectToCss(node)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~GradientObjectParseTests"` +Expected: BUILD FAILURE — `ShapeParsers.ConvertGradientObjectToCss` not defined. + +- [ ] **Step 3: Implement ShapeParsers with the gradient converter** + +Create `src/FlexRender.Yaml/Parsing/ShapeParsers.cs`: + +```csharp +using System.Globalization; +using System.Text; +using FlexRender.Parsing.Ast; +using YamlDotNet.RepresentationModel; +using static FlexRender.Parsing.YamlPropertyHelpers; + +namespace FlexRender.Parsing; + +/// +/// Parsers for shape elements (rect, circle, ellipse, draw), their fill gradients, +/// and the absolute-coordinate draw shapes. +/// +internal static class ShapeParsers +{ + /// + /// Converts a YAML gradient object mapping into FlexRender's CSS-style gradient string + /// so the existing gradient renderer can consume it. + /// + /// The gradient mapping (keys: gradient, colors, angle). + /// A "linear-gradient(...)" or "radial-gradient(...)" string. + /// Thrown on unknown type or fewer than two colors. + internal static string ConvertGradientObjectToCss(YamlMappingNode node) + { + var type = (GetStringValue(node, "gradient") ?? "linear").Trim().ToLowerInvariant(); + + if (!TryGetSequence(node, "colors", out var colorsNode)) + { + throw new TemplateParseException("Gradient fill requires a 'colors' list."); + } + + var colors = new List(colorsNode.Children.Count); + foreach (var child in colorsNode.Children) + { + if (child is YamlScalarNode scalar && !string.IsNullOrWhiteSpace(scalar.Value)) + { + colors.Add(scalar.Value.Trim()); + } + } + + if (colors.Count < 2) + { + throw new TemplateParseException("Gradient fill requires at least two colors."); + } + + var joinedColors = string.Join(", ", colors); + + switch (type) + { + case "linear": + var angle = GetFloatValue(node, "angle", 0f); + var angleStr = angle.ToString(CultureInfo.InvariantCulture); + return $"linear-gradient({angleStr}deg, {joinedColors})"; + + case "radial": + return $"radial-gradient({joinedColors})"; + + default: + throw new TemplateParseException( + $"Unknown gradient type '{type}'. Expected 'linear' or 'radial'."); + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~GradientObjectParseTests"` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Yaml/Parsing/ShapeParsers.cs tests/FlexRender.Tests/Parsing/GradientObjectParseTests.cs +git commit -m "feat(parser): convert gradient object form to css gradient string" +``` + +--- + +## Task 11: Parse box shapes (rect/circle/ellipse) + register in parser/KnownProperties + +**Files:** +- Modify: `src/FlexRender.Yaml/Parsing/ShapeParsers.cs` +- Modify: `src/FlexRender.Yaml/Parsing/TemplateParser.cs` +- Modify: `src/FlexRender.Yaml/Parsing/KnownProperties.cs` +- Test: `tests/FlexRender.Tests/Parsing/ShapeParserTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/ShapeParserTests.cs`: + +```csharp +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for parsing rect/circle/ellipse shape elements from YAML. +/// +public sealed class ShapeParserTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_Rect_SolidFillStrokeRadius() + { + var yaml = """ + canvas: + width: 300 + layout: + - type: rect + width: 100 + height: 50 + fill: "#4A90D9" + stroke: "#333333" + stroke-width: 2 + radius: 4 + """; + + var template = _parser.Parse(yaml); + var rect = Assert.IsType(template.Elements[0]); + + Assert.Equal("#4A90D9", rect.Fill.Value); + Assert.Equal("#333333", rect.Stroke.Value); + Assert.Equal(2f, rect.StrokeWidth.Value); + Assert.Equal("4", rect.Radius.Value); + Assert.Equal("100", rect.Width.Value); + Assert.Equal("50", rect.Height.Value); + } + + [Fact] + public void Parse_Rect_GradientObjectFill_ConvertsToCss() + { + var yaml = """ + canvas: + width: 300 + layout: + - type: rect + width: 100 + height: 100 + fill: + gradient: linear + colors: ["#f00", "#00f"] + angle: 45 + """; + + var template = _parser.Parse(yaml); + var rect = Assert.IsType(template.Elements[0]); + + Assert.Equal("linear-gradient(45deg, #f00, #00f)", rect.Fill.Value); + } + + [Fact] + public void Parse_Circle_SizeShorthand_SetsWidthAndHeight() + { + var yaml = """ + canvas: + width: 300 + layout: + - type: circle + size: 40 + fill: "#e74c3c" + """; + + var template = _parser.Parse(yaml); + var circle = Assert.IsType(template.Elements[0]); + + Assert.Equal("40", circle.Width.Value); + Assert.Equal("40", circle.Height.Value); + Assert.Equal("#e74c3c", circle.Fill.Value); + } + + [Fact] + public void Parse_Ellipse_WidthHeightFill() + { + var yaml = """ + canvas: + width: 300 + layout: + - type: ellipse + width: 120 + height: 60 + fill: "#2ecc71" + """; + + var template = _parser.Parse(yaml); + var ellipse = Assert.IsType(template.Elements[0]); + + Assert.Equal("120", ellipse.Width.Value); + Assert.Equal("60", ellipse.Height.Value); + Assert.Equal("#2ecc71", ellipse.Fill.Value); + } + + [Fact] + public void Parse_Rect_UnknownProperty_SuggestsCorrection() + { + var yaml = """ + canvas: + width: 300 + layout: + - type: rect + fil: "#fff" + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("'fil'", ex.Message); + Assert.Contains("fill", ex.Message); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeParserTests"` +Expected: FAIL — `Unknown element type: 'rect'` (not yet registered). + +- [ ] **Step 3a: Add box-shape parsers to ShapeParsers** + +Append these methods inside the `ShapeParsers` class in `src/FlexRender.Yaml/Parsing/ShapeParsers.cs` +(after `ConvertGradientObjectToCss`): + +```csharp + /// + /// Reads the 'fill' property which may be a solid color string, a gradient string, + /// or a gradient object mapping. Returns an string (CSS for gradients). + /// + private static ExprValue ParseFill(YamlMappingNode node) + { + var fillKey = new YamlScalarNode("fill"); + if (!node.Children.TryGetValue(fillKey, out var fillNode)) + { + return default; + } + + switch (fillNode) + { + case YamlScalarNode scalar when scalar.Value is not null: + return ContainsExpression(scalar.Value) + ? ExprValue.Expression(scalar.Value) + : scalar.Value; + + case YamlMappingNode mapping: + return ConvertGradientObjectToCss(mapping); + + default: + return default; + } + } + + /// + /// Parses a 'rect' shape element. + /// + /// The YAML mapping for the element. + /// The parsed . + internal static TemplateElement ParseRectElement(YamlMappingNode node) + { + var rect = new RectElement + { + Fill = ParseFill(node), + Stroke = GetExprStringValueOptional(node, "stroke"), + StrokeWidth = GetExprFloatValue(node, "stroke-width", 0f), + Radius = GetExprStringValueOptional(node, "radius"), + Rotate = GetExprStringValue(node, "rotate", "none"), + Background = GetStringValue(node, "background")!, + Padding = GetExprStringValue(node, "padding", "0"), + Margin = GetExprStringValue(node, "margin", "0") + }; + + ElementParsers.ApplyFlexItemProperties(node, rect); + return rect; + } + + /// + /// Parses a 'circle' shape element. The 'size' shorthand sets both width and height. + /// + /// The YAML mapping for the element. + /// The parsed . + internal static TemplateElement ParseCircleElement(YamlMappingNode node) + { + var circle = new CircleElement + { + Fill = ParseFill(node), + Stroke = GetExprStringValueOptional(node, "stroke"), + StrokeWidth = GetExprFloatValue(node, "stroke-width", 0f), + Rotate = GetExprStringValue(node, "rotate", "none"), + Background = GetStringValue(node, "background")!, + Padding = GetExprStringValue(node, "padding", "0"), + Margin = GetExprStringValue(node, "margin", "0") + }; + + ElementParsers.ApplyFlexItemProperties(node, circle); + + // 'size' shorthand: applied after ApplyFlexItemProperties so it overrides width/height. + var size = GetStringValue(node, "size"); + if (size is not null) + { + circle.Width = size; + circle.Height = size; + } + + return circle; + } + + /// + /// Parses an 'ellipse' shape element. + /// + /// The YAML mapping for the element. + /// The parsed . + internal static TemplateElement ParseEllipseElement(YamlMappingNode node) + { + var ellipse = new EllipseElement + { + Fill = ParseFill(node), + Stroke = GetExprStringValueOptional(node, "stroke"), + StrokeWidth = GetExprFloatValue(node, "stroke-width", 0f), + Rotate = GetExprStringValue(node, "rotate", "none"), + Background = GetStringValue(node, "background")!, + Padding = GetExprStringValue(node, "padding", "0"), + Margin = GetExprStringValue(node, "margin", "0") + }; + + ElementParsers.ApplyFlexItemProperties(node, ellipse); + return ellipse; + } +``` + +Note: `ApplyFlexItemProperties` is `internal static` on `ElementParsers` — confirm it is callable +from `ShapeParsers` (same assembly, same namespace `FlexRender.Parsing`). It is. + +- [ ] **Step 3b: Register the parsers in TemplateParser** + +In `src/FlexRender.Yaml/Parsing/TemplateParser.cs`, add these entries to the `_elementParsers` +dictionary initializer (after `["content"] = ElementParsers.ParseContentElement`): + +```csharp + ["rect"] = ShapeParsers.ParseRectElement, + ["circle"] = ShapeParsers.ParseCircleElement, + ["ellipse"] = ShapeParsers.ParseEllipseElement, +``` + +(The `draw` entry is added in Task 12.) + +- [ ] **Step 3c: Register known properties** + +In `src/FlexRender.Yaml/Parsing/KnownProperties.cs`, add three property sets after the `Content` +set: + +```csharp + /// + /// Known properties for the 'rect' element type. + /// + internal static readonly HashSet Rect = BuildSet(FlexItemProperties, + [ + "fill", "stroke", "stroke-width", "radius", + "background", "rotate", "padding", "margin" + ]); + + /// + /// Known properties for the 'circle' element type. + /// + internal static readonly HashSet Circle = BuildSet(FlexItemProperties, + [ + "fill", "stroke", "stroke-width", "size", + "background", "rotate", "padding", "margin" + ]); + + /// + /// Known properties for the 'ellipse' element type. + /// + internal static readonly HashSet Ellipse = BuildSet(FlexItemProperties, + [ + "fill", "stroke", "stroke-width", + "background", "rotate", "padding", "margin" + ]); +``` + +Then add registry entries to the `Registry` dictionary (after `["content"] = Content`): + +```csharp + ["rect"] = Rect, + ["circle"] = Circle, + ["ellipse"] = Ellipse, +``` + +(The `draw` set + registry entry is added in Task 12.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeParserTests"` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Yaml/Parsing/ShapeParsers.cs src/FlexRender.Yaml/Parsing/TemplateParser.cs src/FlexRender.Yaml/Parsing/KnownProperties.cs tests/FlexRender.Tests/Parsing/ShapeParserTests.cs +git commit -m "feat(parser): parse rect, circle, ellipse shapes with gradient fill" +``` + +--- + +## Task 12: Parse the draw element + shapes + MaxShapesPerDraw enforcement + +**Files:** +- Modify: `src/FlexRender.Yaml/Parsing/ShapeParsers.cs` +- Modify: `src/FlexRender.Yaml/Parsing/TemplateParser.cs` +- Modify: `src/FlexRender.Yaml/Parsing/KnownProperties.cs` +- Test: `tests/FlexRender.Tests/Parsing/DrawParserTests.cs` + +The `draw` parser needs the resource limit. `TemplateParser` already holds a `ResourceLimits _limits` +field. Register `draw` via an instance lambda that passes `_limits.MaxShapesPerDraw`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/DrawParserTests.cs`: + +```csharp +using FlexRender.Configuration; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for parsing the 'draw' element and its absolute-coordinate shapes. +/// +public sealed class DrawParserTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_Draw_AllShapeKinds() + { + var yaml = """ + canvas: + width: 400 + layout: + - type: draw + width: 400 + height: 200 + shapes: + - line: {x1: 0, y1: 100, x2: 400, y2: 50, stroke: "#333", stroke-width: 2} + - polyline: {points: [[0, 10], [50, 40], [100, 20]], stroke: "#4A90D9"} + - rect: {x: 10, y: 10, width: 80, height: 40, fill: "#eee", radius: 4} + - circle: {cx: 200, cy: 75, r: 30, fill: "#e74c3c"} + - path: {d: "M 0 0 L 100 50 Q 150 0 200 50 Z", fill: "#2ecc71"} + """; + + var template = _parser.Parse(yaml); + var draw = Assert.IsType(template.Elements[0]); + + Assert.Equal(5, draw.Shapes.Count); + + var line = Assert.IsType(draw.Shapes[0]); + Assert.Equal(0f, line.X1); + Assert.Equal(400f, line.X2); + Assert.Equal("#333", line.Stroke); + Assert.Equal(2f, line.StrokeWidth); + + var polyline = Assert.IsType(draw.Shapes[1]); + Assert.Equal(3, polyline.Points.Count); + Assert.Equal(50f, polyline.Points[1].X); + Assert.Equal(40f, polyline.Points[1].Y); + + var rect = Assert.IsType(draw.Shapes[2]); + Assert.Equal(80f, rect.Width); + Assert.Equal("#eee", rect.Fill); + Assert.Equal(4f, rect.Radius); + + var circle = Assert.IsType(draw.Shapes[3]); + Assert.Equal(200f, circle.Cx); + Assert.Equal(30f, circle.R); + + var path = Assert.IsType(draw.Shapes[4]); + Assert.Equal("#2ecc71", path.Fill); + Assert.True(path.Commands.Count >= 4); + } + + [Fact] + public void Parse_Draw_NoShapes_ProducesEmptyList() + { + var yaml = """ + canvas: + width: 400 + layout: + - type: draw + width: 400 + height: 200 + """; + + var template = _parser.Parse(yaml); + var draw = Assert.IsType(template.Elements[0]); + Assert.Empty(draw.Shapes); + } + + [Fact] + public void Parse_Draw_MalformedPath_ThrowsWithCommand() + { + var yaml = """ + canvas: + width: 400 + layout: + - type: draw + width: 400 + height: 200 + shapes: + - path: {d: "M 0 0 X 1 1"} + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("'X'", ex.Message); + } + + [Fact] + public void Parse_Draw_ExceedsShapeLimit_Throws() + { + var limits = new ResourceLimits { MaxShapesPerDraw = 2 }; + var parser = new TemplateParser(limits); + + var yaml = """ + canvas: + width: 400 + layout: + - type: draw + width: 400 + height: 200 + shapes: + - line: {x1: 0, y1: 0, x2: 1, y2: 1} + - line: {x1: 0, y1: 0, x2: 1, y2: 1} + - line: {x1: 0, y1: 0, x2: 1, y2: 1} + """; + + var ex = Assert.Throws(() => parser.Parse(yaml)); + Assert.Contains("shapes", ex.Message); + } + + [Fact] + public void Parse_Draw_UnknownProperty_Throws() + { + var yaml = """ + canvas: + width: 400 + layout: + - type: draw + width: 400 + shaps: [] + """; + + Assert.Throws(() => _parser.Parse(yaml)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawParserTests"` +Expected: FAIL — `Unknown element type: 'draw'`. + +- [ ] **Step 3a: Add the draw parser to ShapeParsers** + +Append these methods inside `ShapeParsers` in `src/FlexRender.Yaml/Parsing/ShapeParsers.cs`: + +```csharp + /// + /// Parses a 'draw' element and its ordered shape list. + /// + /// The YAML mapping for the element. + /// Maximum shapes allowed (resource limit). + /// The parsed . + /// Thrown on malformed shapes or shape-count overflow. + internal static TemplateElement ParseDrawElement(YamlMappingNode node, int maxShapes) + { + var shapes = new List(); + + if (TryGetSequence(node, "shapes", out var shapesNode)) + { + if (shapesNode.Children.Count > maxShapes) + { + throw new TemplateParseException( + $"Draw element has {shapesNode.Children.Count} shapes, exceeding the maximum of {maxShapes}."); + } + + foreach (var item in shapesNode.Children) + { + if (item is YamlMappingNode shapeMapping) + { + shapes.Add(ParseDrawShape(shapeMapping)); + } + } + } + + var draw = new DrawElement(shapes) + { + Rotate = GetExprStringValue(node, "rotate", "none"), + Background = GetStringValue(node, "background")!, + Padding = GetExprStringValue(node, "padding", "0"), + Margin = GetExprStringValue(node, "margin", "0") + }; + + ElementParsers.ApplyFlexItemProperties(node, draw); + return draw; + } + + /// + /// Parses a single shape mapping (one of line/polyline/rect/circle/path). + /// + private static DrawShape ParseDrawShape(YamlMappingNode shapeMapping) + { + if (TryGetMapping(shapeMapping, "line", out var lineNode)) + return ParseDrawLine(lineNode); + if (TryGetMapping(shapeMapping, "polyline", out var polylineNode)) + return ParseDrawPolyline(polylineNode); + if (TryGetMapping(shapeMapping, "rect", out var rectNode)) + return ParseDrawRect(rectNode); + if (TryGetMapping(shapeMapping, "circle", out var circleNode)) + return ParseDrawCircle(circleNode); + if (TryGetMapping(shapeMapping, "path", out var pathNode)) + return ParseDrawPath(pathNode); + + throw new TemplateParseException( + "Each draw shape must be one of: line, polyline, rect, circle, path."); + } + + private static DrawShape ParseDrawLine(YamlMappingNode node) => new DrawLine( + GetFloatValue(node, "x1", 0f), + GetFloatValue(node, "y1", 0f), + GetFloatValue(node, "x2", 0f), + GetFloatValue(node, "y2", 0f), + GetStringValue(node, "stroke"), + GetFloatValue(node, "stroke-width", 1f)); + + private static DrawShape ParseDrawPolyline(YamlMappingNode node) + { + var points = ParsePoints(node); + return new DrawPolyline( + points, + GetStringValue(node, "stroke"), + GetFloatValue(node, "stroke-width", 1f), + GetStringValue(node, "fill")); + } + + private static DrawShape ParseDrawRect(YamlMappingNode node) => new DrawRect( + GetFloatValue(node, "x", 0f), + GetFloatValue(node, "y", 0f), + GetFloatValue(node, "width", 0f), + GetFloatValue(node, "height", 0f), + GetStringValue(node, "fill"), + GetStringValue(node, "stroke"), + GetFloatValue(node, "stroke-width", 0f), + GetFloatValue(node, "radius", 0f)); + + private static DrawShape ParseDrawCircle(YamlMappingNode node) => new DrawCircle( + GetFloatValue(node, "cx", 0f), + GetFloatValue(node, "cy", 0f), + GetFloatValue(node, "r", 0f), + GetStringValue(node, "fill"), + GetStringValue(node, "stroke"), + GetFloatValue(node, "stroke-width", 0f)); + + private static DrawShape ParseDrawPath(YamlMappingNode node) + { + var d = GetStringValue(node, "d") ?? string.Empty; + IReadOnlyList commands; + try + { + commands = PathDataParser.Parse(d); + } + catch (PathParseException ex) + { + throw new TemplateParseException($"Invalid path data: {ex.Message}", ex); + } + + return new DrawPath( + commands, + GetStringValue(node, "fill"), + GetStringValue(node, "stroke"), + GetFloatValue(node, "stroke-width", 0f)); + } + + /// + /// Parses a 'points' sequence of [x, y] pairs. + /// + private static List ParsePoints(YamlMappingNode node) + { + var points = new List(); + if (!TryGetSequence(node, "points", out var pointsNode)) + return points; + + foreach (var item in pointsNode.Children) + { + if (item is YamlSequenceNode pair && pair.Children.Count >= 2 && + pair.Children[0] is YamlScalarNode xs && pair.Children[1] is YamlScalarNode ys && + float.TryParse(xs.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) && + float.TryParse(ys.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) + { + points.Add(new PathPoint(x, y)); + } + } + return points; + } +``` + +Note: `GetFloatValue` and `GetStringValue(node, key)` (the nullable overload) come from +`YamlPropertyHelpers` (already `using static`). `PathPoint`, `PathCommand`, `PathParseException`, +`PathDataParser` are in `FlexRender.Parsing` (same namespace). The `using` directives at the top +of the file already include `System.Globalization` and `FlexRender.Parsing.Ast`. + +- [ ] **Step 3b: Register the draw parser** + +In `src/FlexRender.Yaml/Parsing/TemplateParser.cs`, add to the `_elementParsers` initializer +(after the `ellipse` entry from Task 11): + +```csharp + ["draw"] = node => ShapeParsers.ParseDrawElement(node, _limits.MaxShapesPerDraw), +``` + +- [ ] **Step 3c: Register draw known properties** + +In `src/FlexRender.Yaml/Parsing/KnownProperties.cs`, add the set (after `Ellipse`): + +```csharp + /// + /// Known properties for the 'draw' element type. + /// + internal static readonly HashSet Draw = BuildSet(FlexItemProperties, + [ + "shapes", + "background", "rotate", "padding", "margin" + ]); +``` + +And the registry entry (after `["ellipse"] = Ellipse`): + +```csharp + ["draw"] = Draw, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawParserTests"` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Yaml/Parsing/ShapeParsers.cs src/FlexRender.Yaml/Parsing/TemplateParser.cs src/FlexRender.Yaml/Parsing/KnownProperties.cs tests/FlexRender.Tests/Parsing/DrawParserTests.cs +git commit -m "feat(parser): parse draw element with absolute shapes and shape limit" +``` + +--- + +## Task 13: Skia rendering — box shapes (rect/circle/ellipse) + +**Files:** +- Create: `src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs` +- Modify: `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs` +- Test: snapshot in Task 15 (rendering wired here; visual verification follows) + +This task wires drawing into the existing dispatch. We verify with a non-snapshot unit test that +the shape elements no longer fall through to the default branch (i.e., a render of a rect does not +throw and produces non-background pixels). Snapshot golden images come in Task 15. + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ShapeRenderSmokeTests.cs`: + +```csharp +using FlexRender.Configuration; +using FlexRender.Parsing; +using FlexRender.Rendering; +using FlexRender.TemplateEngine; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Smoke tests confirming shape elements render visible (non-background) pixels. +/// +public sealed class ShapeRenderSmokeTests +{ + [Fact] + public async Task Rect_RendersFilledPixels() + { + var yaml = """ + canvas: + width: 120 + height: 80 + background: "#ffffff" + layout: + - type: rect + width: 100 + height: 50 + fill: "#ff0000" + margin: "10" + """; + + var parser = new TemplateParser(); + var template = parser.Parse(yaml); + + using var renderer = new SkiaRenderer(new ResourceLimits(), deterministicRendering: true); + var size = await renderer.MeasureAsync(template, new ObjectValue()); + using var bitmap = new SKBitmap((int)System.Math.Ceiling(size.Width), (int)System.Math.Ceiling(size.Height), SKColorType.Rgba8888, SKAlphaType.Premul); + await renderer.Render(bitmap, template, new ObjectValue(), default, default); + + // Center of the rect should be red, not white. + var center = bitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2); + Assert.True(center.Red > 200 && center.Green < 80 && center.Blue < 80, + $"Expected red center pixel, got {center}."); + } +} +``` + +Note: Verify the exact `SkiaRenderer` constructor used by tests. `SnapshotTestBase` constructs it +as `new SkiaRenderer(new ResourceLimits(), new QrProvider(), new BarcodeProvider(), imageLoader: null, deterministicRendering: true)`. If a parameterless-limits-only `SkiaRenderer(ResourceLimits, deterministicRendering:)` overload does not exist, use the same constructor form as `SnapshotTestBase` (pass `new QrProvider(), new BarcodeProvider(), imageLoader: null`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeRenderSmokeTests"` +Expected: FAIL — rect falls through the renderer's default switch arm and draws nothing; center pixel stays white. + +- [ ] **Step 3a: Implement ShapeRenderer (box shapes)** + +Create `src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs`: + +```csharp +using FlexRender.Parsing.Ast; +using SkiaSharp; + +namespace FlexRender.Rendering; + +/// +/// Draws shape elements (rect, circle, ellipse) and the draw element's absolute shapes +/// onto an . +/// +internal static class ShapeRenderer +{ + /// + /// Draws a rectangle shape (fill and/or stroke, optional rounded corners). + /// + /// The target canvas. + /// The rectangle element. + /// Box X. + /// Box Y. + /// Box width. + /// Box height. + /// Effective font size for em-relative radius. + /// Whether to antialias. + internal static void DrawRect(SKCanvas canvas, RectElement rect, float x, float y, float width, float height, float fontSize, bool antialias) + { + var radius = ResolveRadius(rect.Radius.Value, fontSize); + + if (!string.IsNullOrEmpty(rect.Fill.Value)) + { + using var fillPaint = CreateFillPaint(rect.Fill.Value, x, y, width, height, antialias); + if (fillPaint is not null) + { + DrawRoundOrSharpRect(canvas, x, y, width, height, radius, fillPaint); + } + } + + if (HasStroke(rect.Stroke.Value, rect.StrokeWidth.Value)) + { + using var strokePaint = CreateStrokePaint(rect.Stroke.Value!, rect.StrokeWidth.Value, antialias); + DrawRoundOrSharpRect(canvas, x, y, width, height, radius, strokePaint); + } + } + + /// + /// Draws a circle shape inscribed in the box (diameter = min(width, height)). + /// + internal static void DrawCircle(SKCanvas canvas, CircleElement circle, float x, float y, float width, float height, bool antialias) + { + var diameter = System.Math.Min(width, height); + var r = diameter / 2f; + var cx = x + width / 2f; + var cy = y + height / 2f; + + if (!string.IsNullOrEmpty(circle.Fill.Value)) + { + using var fillPaint = CreateFillPaint(circle.Fill.Value, x, y, width, height, antialias); + if (fillPaint is not null) + canvas.DrawCircle(cx, cy, r, fillPaint); + } + + if (HasStroke(circle.Stroke.Value, circle.StrokeWidth.Value)) + { + using var strokePaint = CreateStrokePaint(circle.Stroke.Value!, circle.StrokeWidth.Value, antialias); + canvas.DrawCircle(cx, cy, r, strokePaint); + } + } + + /// + /// Draws an ellipse shape inscribed in the box. + /// + internal static void DrawEllipse(SKCanvas canvas, EllipseElement ellipse, float x, float y, float width, float height, bool antialias) + { + var cx = x + width / 2f; + var cy = y + height / 2f; + var rx = width / 2f; + var ry = height / 2f; + + if (!string.IsNullOrEmpty(ellipse.Fill.Value)) + { + using var fillPaint = CreateFillPaint(ellipse.Fill.Value, x, y, width, height, antialias); + if (fillPaint is not null) + canvas.DrawOval(cx, cy, rx, ry, fillPaint); + } + + if (HasStroke(ellipse.Stroke.Value, ellipse.StrokeWidth.Value)) + { + using var strokePaint = CreateStrokePaint(ellipse.Stroke.Value!, ellipse.StrokeWidth.Value, antialias); + canvas.DrawOval(cx, cy, rx, ry, strokePaint); + } + } + + private static void DrawRoundOrSharpRect(SKCanvas canvas, float x, float y, float width, float height, float radius, SKPaint paint) + { + if (radius > 0f) + canvas.DrawRoundRect(x, y, width, height, radius, radius, paint); + else + canvas.DrawRect(x, y, width, height, paint); + } + + private static bool HasStroke(string? stroke, float strokeWidth) + => !string.IsNullOrEmpty(stroke) && strokeWidth > 0f; + + private static float ResolveRadius(string? radius, float fontSize) + { + if (string.IsNullOrWhiteSpace(radius)) + return 0f; + + var unit = Layout.Units.UnitParser.Parse(radius); + return unit.Resolve(0f, fontSize) ?? 0f; + } + + /// + /// Creates a fill paint that handles both solid colors and gradient strings. + /// Returns null when the fill value is empty. + /// + internal static SKPaint? CreateFillPaint(string? fill, float x, float y, float width, float height, bool antialias) + { + if (string.IsNullOrEmpty(fill)) + return null; + + if (GradientParser.IsGradient(fill) && GradientParser.TryParse(fill, out var gradient) && gradient is not null) + { + var shader = GradientParser.CreateShader(gradient, x, y, width, height); + if (shader is not null) + { + return new SKPaint { Shader = shader, Style = SKPaintStyle.Fill, IsAntialias = antialias }; + } + } + + return new SKPaint { Color = ColorParser.Parse(fill), Style = SKPaintStyle.Fill, IsAntialias = antialias }; + } + + /// + /// Creates a stroke paint with the given color and width. + /// + internal static SKPaint CreateStrokePaint(string stroke, float strokeWidth, bool antialias) + => new() + { + Color = ColorParser.Parse(stroke), + StrokeWidth = strokeWidth, + Style = SKPaintStyle.Stroke, + IsAntialias = antialias + }; +} +``` + +Note: `SKPaint.Shader` must be disposed with the paint. Disposing the `SKPaint` (via `using`) does +not dispose the shader, so explicitly dispose the shader after drawing. To keep the API simple, the +caller uses `using var fillPaint = ...`; the shader leak is avoided by wrapping shader disposal. +Revise `CreateFillPaint`'s gradient branch to attach the shader and document that the caller must +dispose. Implement instead with a paint that owns disposal by using `SKPaint` + manual shader dispose +at call sites is error-prone — so the simplest correct approach: in `CreateFillPaint`, after creating +the shader, set it on the paint and store the shader in the paint only; then at each call site, after +`canvas.DrawX(...)`, call `fillPaint.Shader?.Dispose()` is NOT available. Therefore: keep the shader +local. To do this cleanly, change `CreateFillPaint` gradient branch to: + +```csharp + if (GradientParser.IsGradient(fill) && GradientParser.TryParse(fill, out var gradient) && gradient is not null) + { + var shader = GradientParser.CreateShader(gradient, x, y, width, height); + if (shader is not null) + { + var paint = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = antialias }; + paint.Shader = shader; // paint takes a reference; dispose shader after the paint + return paint; + } + } +``` + +And at each box-shape fill call site, after drawing, dispose the shader explicitly: + +```csharp + using var fillPaint = CreateFillPaint(rect.Fill.Value, x, y, width, height, antialias); + if (fillPaint is not null) + { + DrawRoundOrSharpRect(canvas, x, y, width, height, radius, fillPaint); + fillPaint.Shader?.Dispose(); + } +``` + +Apply the same `fillPaint.Shader?.Dispose();` after `canvas.DrawCircle(...)` and +`canvas.DrawOval(...)` in `DrawCircle`/`DrawEllipse`. (For solid-color paints `Shader` is null, so +the call is a safe no-op.) + +- [ ] **Step 3b: Dispatch the box shapes in RenderingEngine** + +In `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs`, in `DrawElement`'s `switch (element)`, +add cases before the `case SeparatorElement separator:` arm: + +```csharp + case RectElement rect: + ShapeRenderer.DrawRect(canvas, rect, x, y, width, height, effectiveFontSize, renderOptions.Antialiasing); + break; + + case CircleElement circle: + ShapeRenderer.DrawCircle(canvas, circle, x, y, width, height, renderOptions.Antialiasing); + break; + + case EllipseElement ellipse: + ShapeRenderer.DrawEllipse(canvas, ellipse, x, y, width, height, renderOptions.Antialiasing); + break; +``` + +(The `DrawElement` case is added in Task 14.) Confirm `effectiveFontSize` and +`renderOptions.Antialiasing` are in scope in `DrawElement` — they are (see the existing +`SeparatorElement` arm and `effectiveFontSize` local at the top of `DrawElement`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeRenderSmokeTests"` +Expected: PASS (1 test). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs tests/FlexRender.Tests/Rendering/ShapeRenderSmokeTests.cs +git commit -m "feat(renderer): draw rect, circle, ellipse shapes via skia" +``` + +--- + +## Task 14: Skia rendering — draw element shapes + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs` +- Modify: `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs` +- Test: `tests/FlexRender.Tests/Rendering/DrawRenderSmokeTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/DrawRenderSmokeTests.cs`: + +```csharp +using FlexRender.Configuration; +using FlexRender.Parsing; +using FlexRender.Rendering; +using FlexRender.TemplateEngine; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Smoke tests confirming the draw element paints its shapes. +/// +public sealed class DrawRenderSmokeTests +{ + [Fact] + public async Task Draw_FilledCircle_RendersColoredPixels() + { + var yaml = """ + canvas: + width: 200 + height: 150 + background: "#ffffff" + layout: + - type: draw + width: 200 + height: 150 + shapes: + - circle: {cx: 100, cy: 75, r: 40, fill: "#0000ff"} + """; + + var parser = new TemplateParser(); + var template = parser.Parse(yaml); + + using var renderer = new SkiaRenderer(new ResourceLimits(), deterministicRendering: true); + var size = await renderer.MeasureAsync(template, new ObjectValue()); + using var bitmap = new SKBitmap((int)System.Math.Ceiling(size.Width), (int)System.Math.Ceiling(size.Height), SKColorType.Rgba8888, SKAlphaType.Premul); + await renderer.Render(bitmap, template, new ObjectValue(), default, default); + + var center = bitmap.GetPixel(100, 75); + Assert.True(center.Blue > 200 && center.Red < 80 && center.Green < 80, + $"Expected blue center pixel, got {center}."); + } +} +``` + +Note: same `SkiaRenderer` constructor caveat as Task 13. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawRenderSmokeTests"` +Expected: FAIL — `DrawElement` falls through; center stays white. + +- [ ] **Step 3a: Add DrawShapes rendering to ShapeRenderer** + +Append to `src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs` (inside the `ShapeRenderer` class). +Add `using FlexRender.Parsing;` at the top of the file (for `PathCommand`/`PathCommandKind`): + +```csharp + /// + /// Draws all shapes of a draw element, clipped to the element box and offset to (x, y). + /// + /// The target canvas. + /// The draw element. + /// Box X (origin for absolute shape coordinates). + /// Box Y. + /// Box width. + /// Box height. + /// Whether to antialias. + internal static void DrawShapes(SKCanvas canvas, DrawElement draw, float x, float y, float width, float height, bool antialias) + { + canvas.Save(); + canvas.ClipRect(new SKRect(x, y, x + width, y + height)); + canvas.Translate(x, y); + + foreach (var shape in draw.Shapes) + { + switch (shape) + { + case DrawLine line: + DrawShapeLine(canvas, line, antialias); + break; + case DrawPolyline polyline: + DrawShapePolyline(canvas, polyline, antialias); + break; + case DrawRect rect: + DrawShapeRect(canvas, rect, antialias); + break; + case DrawCircle circle: + DrawShapeCircle(canvas, circle, antialias); + break; + case DrawPath path: + DrawShapePath(canvas, path, antialias); + break; + } + } + + canvas.Restore(); + } + + private static void DrawShapeLine(SKCanvas canvas, DrawLine line, bool antialias) + { + using var paint = CreateStrokePaint(line.Stroke ?? "#000000", line.StrokeWidth, antialias); + canvas.DrawLine(line.X1, line.Y1, line.X2, line.Y2, paint); + } + + private static void DrawShapePolyline(SKCanvas canvas, DrawPolyline polyline, bool antialias) + { + if (polyline.Points.Count < 2) + return; + + using var path = new SKPath(); + path.MoveTo(polyline.Points[0].X, polyline.Points[0].Y); + for (var i = 1; i < polyline.Points.Count; i++) + path.LineTo(polyline.Points[i].X, polyline.Points[i].Y); + + if (!string.IsNullOrEmpty(polyline.Fill)) + { + using var fillPaint = new SKPaint { Color = ColorParser.Parse(polyline.Fill), Style = SKPaintStyle.Fill, IsAntialias = antialias }; + canvas.DrawPath(path, fillPaint); + } + + using var strokePaint = CreateStrokePaint(polyline.Stroke ?? "#000000", polyline.StrokeWidth, antialias); + canvas.DrawPath(path, strokePaint); + } + + private static void DrawShapeRect(SKCanvas canvas, DrawRect rect, bool antialias) + { + if (!string.IsNullOrEmpty(rect.Fill)) + { + using var fillPaint = new SKPaint { Color = ColorParser.Parse(rect.Fill), Style = SKPaintStyle.Fill, IsAntialias = antialias }; + DrawRoundOrSharpRect(canvas, rect.X, rect.Y, rect.Width, rect.Height, rect.Radius, fillPaint); + } + + if (HasStroke(rect.Stroke, rect.StrokeWidth)) + { + using var strokePaint = CreateStrokePaint(rect.Stroke!, rect.StrokeWidth, antialias); + DrawRoundOrSharpRect(canvas, rect.X, rect.Y, rect.Width, rect.Height, rect.Radius, strokePaint); + } + } + + private static void DrawShapeCircle(SKCanvas canvas, DrawCircle circle, bool antialias) + { + if (!string.IsNullOrEmpty(circle.Fill)) + { + using var fillPaint = new SKPaint { Color = ColorParser.Parse(circle.Fill), Style = SKPaintStyle.Fill, IsAntialias = antialias }; + canvas.DrawCircle(circle.Cx, circle.Cy, circle.R, fillPaint); + } + + if (HasStroke(circle.Stroke, circle.StrokeWidth)) + { + using var strokePaint = CreateStrokePaint(circle.Stroke!, circle.StrokeWidth, antialias); + canvas.DrawCircle(circle.Cx, circle.Cy, circle.R, strokePaint); + } + } + + private static void DrawShapePath(SKCanvas canvas, DrawPath drawPath, bool antialias) + { + using var path = new SKPath(); + foreach (var command in drawPath.Commands) + { + switch (command.Kind) + { + case PathCommandKind.MoveTo: + path.MoveTo(command.Points[0].X, command.Points[0].Y); + break; + case PathCommandKind.LineTo: + path.LineTo(command.Points[0].X, command.Points[0].Y); + break; + case PathCommandKind.QuadTo: + path.QuadTo(command.Points[0].X, command.Points[0].Y, command.Points[1].X, command.Points[1].Y); + break; + case PathCommandKind.CubicTo: + path.CubicTo( + command.Points[0].X, command.Points[0].Y, + command.Points[1].X, command.Points[1].Y, + command.Points[2].X, command.Points[2].Y); + break; + case PathCommandKind.Close: + path.Close(); + break; + } + } + + if (!string.IsNullOrEmpty(drawPath.Fill)) + { + using var fillPaint = new SKPaint { Color = ColorParser.Parse(drawPath.Fill), Style = SKPaintStyle.Fill, IsAntialias = antialias }; + canvas.DrawPath(path, fillPaint); + } + + if (HasStroke(drawPath.Stroke, drawPath.StrokeWidth)) + { + using var strokePaint = CreateStrokePaint(drawPath.Stroke!, drawPath.StrokeWidth, antialias); + canvas.DrawPath(path, strokePaint); + } + } +``` + +Note: `HasStroke` and `CreateStrokePaint` already exist from Task 13. The `using FlexRender.Parsing.Ast;` +is already at the top of the file from Task 13. Add `using FlexRender.Parsing;` for the path command types. + +- [ ] **Step 3b: Dispatch DrawElement in RenderingEngine** + +In `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs`, in `DrawElement`'s `switch (element)`, +add this case after the `EllipseElement` arm from Task 13: + +```csharp + case DrawElement drawEl: + ShapeRenderer.DrawShapes(canvas, drawEl, x, y, width, height, renderOptions.Antialiasing); + break; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~DrawRenderSmokeTests"` +Expected: PASS (1 test). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs tests/FlexRender.Tests/Rendering/DrawRenderSmokeTests.cs +git commit -m "feat(renderer): draw line, polyline, rect, circle, path inside draw element" +``` + +--- + +## Task 15: Snapshot tests (golden images) + +**Files:** +- Create: `tests/FlexRender.Tests/Snapshots/ShapeSnapshotTests.cs` +- Generated: `tests/FlexRender.Tests/Snapshots/golden/shapes_box_basic.png`, + `tests/FlexRender.Tests/Snapshots/golden/shapes_gradient.png`, + `tests/FlexRender.Tests/Snapshots/golden/draw_overlap.png` + +- [ ] **Step 1: Write the snapshot tests** + +Create `tests/FlexRender.Tests/Snapshots/ShapeSnapshotTests.cs`: + +```csharp +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Snapshots; + +/// +/// Visual snapshot tests for shape primitives and the draw element. +/// Run with UPDATE_SNAPSHOTS=true to regenerate golden images. +/// +public sealed class ShapeSnapshotTests : SnapshotTestBase +{ + private static Template CreateTemplate(int width, int height) + => new() + { + Canvas = new CanvasSettings + { + Fixed = FixedDimension.Both, + Width = width, + Height = height, + Background = "#ffffff" + } + }; + + [Fact] + public async Task Shapes_BoxBasic_RectCircleEllipse() + { + var template = CreateTemplate(260, 90); + + var yaml = """ + canvas: + width: 260 + height: 90 + fixed: both + background: "#ffffff" + layout: + - type: flex + direction: row + gap: "10" + padding: "10" + align: center + children: + - type: rect + width: 70 + height: 50 + fill: "#4A90D9" + stroke: "#1f3a5f" + stroke-width: 2 + radius: 6 + - type: circle + size: 50 + fill: "#e74c3c" + - type: ellipse + width: 80 + height: 50 + fill: "#2ecc71" + stroke: "#145a32" + stroke-width: 2 + """; + + var parsed = Parser.Parse(yaml); + await AssertSnapshot("shapes_box_basic", parsed, new ObjectValue()); + } + + [Fact] + public async Task Shapes_Gradient_LinearAndRadial() + { + var yaml = """ + canvas: + width: 220 + height: 110 + fixed: both + background: "#ffffff" + layout: + - type: flex + direction: row + gap: "10" + padding: "10" + children: + - type: rect + width: 90 + height: 90 + fill: + gradient: linear + colors: ["#ff0000", "#0000ff"] + angle: 45 + - type: circle + size: 90 + fill: + gradient: radial + colors: ["#ffffff", "#222222"] + """; + + var parsed = Parser.Parse(yaml); + await AssertSnapshot("shapes_gradient", parsed, new ObjectValue()); + } + + [Fact] + public async Task Draw_Overlap_PaintersOrder() + { + var yaml = """ + canvas: + width: 200 + height: 160 + fixed: both + background: "#ffffff" + layout: + - type: draw + width: 200 + height: 160 + shapes: + - rect: {x: 20, y: 20, width: 120, height: 80, fill: "#cccccc", radius: 8} + - line: {x1: 0, y1: 80, x2: 200, y2: 40, stroke: "#333333", stroke-width: 3} + - polyline: {points: [[10, 140], [60, 110], [110, 130], [160, 100]], stroke: "#4A90D9", stroke-width: 2} + - circle: {cx: 130, cy: 70, r: 35, fill: "#e74c3c"} + - path: {d: "M 20 150 L 80 110 Q 120 90 160 120 Z", fill: "#2ecc71"} + """; + + var parsed = Parser.Parse(yaml); + await AssertSnapshot("draw_overlap", parsed, new ObjectValue()); + } +} +``` + +Note: `CreateTemplate` is unused if all three tests parse YAML directly; remove it to avoid an +unused-private-method warning (`TreatWarningsAsErrors=true`). Delete the `CreateTemplate` method +from the file before building. + +- [ ] **Step 2: Run tests to verify they fail (no golden yet)** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeSnapshotTests"` +Expected: FAIL — "Golden image not found" for all three tests (actual images written to `Snapshots/output/`). + +- [ ] **Step 3: Generate golden images** + +Run: `UPDATE_SNAPSHOTS=true dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeSnapshotTests"` +Expected: PASS (update mode writes goldens, asserts nothing). Three PNGs appear in +`tests/FlexRender.Tests/Snapshots/golden/`. + +- [ ] **Step 4: Re-run without update to verify goldens match** + +Run: `dotnet test FlexRender.slnx --filter "FullyQualifiedName~ShapeSnapshotTests"` +Expected: PASS (3 tests). + +Manual check: open the three PNGs in `tests/FlexRender.Tests/Snapshots/golden/` and confirm the +shapes look correct (rounded blue rect with border, red circle, green ellipse; gradients smooth; +draw shapes overlap in list order). + +- [ ] **Step 5: Commit** + +```bash +git add tests/FlexRender.Tests/Snapshots/ShapeSnapshotTests.cs tests/FlexRender.Tests/Snapshots/golden/shapes_box_basic.png tests/FlexRender.Tests/Snapshots/golden/shapes_gradient.png tests/FlexRender.Tests/Snapshots/golden/draw_overlap.png +git commit -m "test(renderer): add shape and draw snapshot golden images" +``` + +--- + +## Task 16: Full build + full test suite gate + +**Files:** none (verification only). + +- [ ] **Step 1: Build the whole solution** + +Run: `dotnet build FlexRender.slnx` +Expected: BUILD SUCCEEDED, zero warnings (`TreatWarningsAsErrors=true`). + +- [ ] **Step 2: Run the entire test suite** + +Run: `dotnet test FlexRender.slnx` +Expected: PASS — all pre-existing tests plus the new ones (1264+ → 1264+ ~ +40). No regressions. + +- [ ] **Step 3: If any pre-existing snapshot/test fails** + +Investigate; new element enum members or dispatch arms must not change existing rendering. +Do not regenerate unrelated goldens. Fix the cause, re-run. + +- [ ] **Step 4: Commit (only if a fix was needed)** + +```bash +git add -A +git commit -m "fix: resolve regressions surfaced by full suite after shapes" +``` + +If no fix was needed, skip this commit. + +--- + +## Task 17: Docs — llms.txt and llms-full.txt + +**Files:** +- Modify: `llms.txt` +- Modify: `llms-full.txt` + +These are documentation; no test. Mirror the style of the existing `separator`/`svg` entries. + +- [ ] **Step 1: Inspect existing element documentation** + +Run: `dotnet --version` (no-op placeholder). Then open `llms.txt` and locate the element-type list +and the `separator` description. Open `llms-full.txt` and locate the per-element property reference +(search for "separator"). + +- [ ] **Step 2: Add shape entries to llms.txt** + +In `llms.txt`, in the element-type overview list, add `rect`, `circle`, `ellipse`, `draw` alongside +the existing element types. Add a concise block (matching the file's existing format) such as: + +``` +- rect/circle/ellipse: shape boxes. Props: fill (color or gradient object), stroke, stroke-width, opacity, radius (rect only), and for circle the `size` shorthand. Participate in flex layout. +- draw: free-form drawing box. Holds `shapes:` (absolute coords): line {x1,y1,x2,y2,stroke,stroke-width}, polyline {points:[[x,y],...],stroke,fill}, rect {x,y,width,height,fill,stroke,radius}, circle {cx,cy,r,fill,stroke}, path {d:"M L Q C Z absolute",fill,stroke}. Max shapes = ResourceLimits.MaxShapesPerDraw (1000). Gradient fill object: {gradient: linear|radial, colors: [..], angle: deg}. +``` + +Place these entries in the same section and ordering style as the existing elements. + +- [ ] **Step 3: Add shape reference to llms-full.txt** + +In `llms-full.txt`, add a full per-element subsection for `rect`, `circle`, `ellipse`, and `draw`, +matching the structure used for `separator`/`table`/`svg`. Include: +- Property tables (fill including gradient object form; stroke; stroke-width; opacity; radius for rect; size for circle). +- The `draw` shape grammar for line/polyline/rect/circle/path, noting absolute coordinates and the supported path commands M/L/Q/C/Z (absolute only). +- The `MaxShapesPerDraw` limit (default 1000) in the resource-limits table. +- A YAML example for each element type (use the examples from this plan's Task 11/12 tests). + +- [ ] **Step 4: Verify docs build (smoke)** + +Run: `dotnet build FlexRender.slnx` +Expected: BUILD SUCCEEDED (docs are text; this just confirms nothing referencing them broke). + +- [ ] **Step 5: Commit** + +```bash +git add llms.txt llms-full.txt +git commit -m "docs: document rect, circle, ellipse, draw shapes in llms files" +``` + +--- + +## Task 18: Docs — wiki Element-Reference and Visual-Reference + +**Files:** +- Modify: `docs/wiki/Element-Reference.md` +- Modify: `docs/wiki/Visual-Reference.md` + +- [ ] **Step 1: Add element entries to Element-Reference.md** + +Open `docs/wiki/Element-Reference.md`. Find the table of contents / element list and the existing +`separator` section. Add four new sections — `rect`, `circle`, `ellipse`, `draw` — each with: +- Description. +- A property table (columns: Property, Type, Default, Description). +- A minimal YAML example. + +Property rows for box shapes: + +| Property | Type | Default | Description | +|---|---|---|---| +| `fill` | string or object | none | Solid color or gradient object `{gradient, colors, angle}` | +| `stroke` | string | none | Stroke color (hex) | +| `stroke-width` | number | 0 | Stroke width in px | +| `opacity` | number | 1.0 | 0..1, inherited base property | +| `radius` | unit | none | Corner radius (rect only) | +| `size` | unit | none | Sets width and height (circle only) | + +For `draw`, document `shapes:` and the five shape kinds with their property keys, noting absolute +coordinates relative to the element box and the supported path commands (M, L, Q, C, Z — absolute only). + +- [ ] **Step 2: Add visuals to Visual-Reference.md** + +Open `docs/wiki/Visual-Reference.md`. Following the existing pattern (a short caption + a YAML snippet ++ a reference to a rendered image), add entries for shapes and draw. Use the YAML from Task 15's +snapshot tests. If the file references images by path, point to the golden PNGs generated in Task 15 +(`tests/FlexRender.Tests/Snapshots/golden/shapes_box_basic.png`, `shapes_gradient.png`, +`draw_overlap.png`) using the repo's documented image-URL convention from AGENTS.md +(`media.githubusercontent.com/.../` for committed binaries). Match whatever convention the +existing entries already use. + +- [ ] **Step 3: Commit** + +```bash +git add docs/wiki/Element-Reference.md docs/wiki/Visual-Reference.md +git commit -m "docs(wiki): add rect, circle, ellipse, draw to element and visual references" +``` + +--- + +## Task 19: Docs — Playground JSON schema + autocomplete + +**Files:** +- Modify: `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json` +- Modify: `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` (only if needed) + +The schema uses `definitions/element` with an `allOf` list of `if/then` `$ref` branches and an +`enum` of element-type names. New element schemas plug into both. + +- [ ] **Step 1: Add the type enum and dispatch branches** + +In `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`: + +1. In the element `type` enum (the line listing `["text", "flex", ..., "content"]`), add + `"rect"`, `"circle"`, `"ellipse"`, `"draw"`. + +2. In the `allOf` list of `if/then` branches (after the `content` branch), add four branches: + +```json + { + "if": { + "properties": { "type": { "const": "rect" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/rectElement" } + }, + { + "if": { + "properties": { "type": { "const": "circle" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/circleElement" } + }, + { + "if": { + "properties": { "type": { "const": "ellipse" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/ellipseElement" } + }, + { + "if": { + "properties": { "type": { "const": "draw" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/drawElement" } + } +``` + +(Match the exact `if/then` shape of the existing branches — confirm whether existing branches use +`"required": ["type"]`; mirror them precisely.) + +- [ ] **Step 2: Add the element definitions** + +In the same file, under `definitions`, add four definitions following the structure of +`separatorElement` (each uses `allOf` referencing `flexItemProperties` and lists its own properties). +Example for `rectElement`: + +```json + "rectElement": { + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "rect" }, + "fill": { + "description": "Solid color (hex) or gradient object.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/gradientFill" } + ] + }, + "stroke": { "type": "string", "description": "Stroke color (hex)." }, + "stroke-width": { "type": "number", "description": "Stroke width in px." }, + "radius": { "type": ["number", "string"], "description": "Corner radius (px/em)." } + } + }, + "circleElement": { + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "circle" }, + "fill": { + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/gradientFill" } + ] + }, + "stroke": { "type": "string" }, + "stroke-width": { "type": "number" }, + "size": { "type": ["number", "string"], "description": "Sets width and height (diameter)." } + } + }, + "ellipseElement": { + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "ellipse" }, + "fill": { + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/gradientFill" } + ] + }, + "stroke": { "type": "string" }, + "stroke-width": { "type": "number" } + } + }, + "drawElement": { + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "draw" }, + "shapes": { + "type": "array", + "description": "Ordered absolute-coordinate shapes.", + "items": { "$ref": "#/definitions/drawShape" } + } + } + }, + "gradientFill": { + "type": "object", + "properties": { + "gradient": { "type": "string", "enum": ["linear", "radial"] }, + "colors": { "type": "array", "items": { "type": "string" }, "minItems": 2 }, + "angle": { "type": "number", "description": "Linear gradient angle in degrees." } + }, + "required": ["gradient", "colors"] + }, + "drawShape": { + "type": "object", + "properties": { + "line": { + "type": "object", + "properties": { + "x1": { "type": "number" }, "y1": { "type": "number" }, + "x2": { "type": "number" }, "y2": { "type": "number" }, + "stroke": { "type": "string" }, "stroke-width": { "type": "number" } + } + }, + "polyline": { + "type": "object", + "properties": { + "points": { "type": "array", "items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 } }, + "stroke": { "type": "string" }, "stroke-width": { "type": "number" }, "fill": { "type": "string" } + } + }, + "rect": { + "type": "object", + "properties": { + "x": { "type": "number" }, "y": { "type": "number" }, + "width": { "type": "number" }, "height": { "type": "number" }, + "fill": { "type": "string" }, "stroke": { "type": "string" }, + "stroke-width": { "type": "number" }, "radius": { "type": "number" } + } + }, + "circle": { + "type": "object", + "properties": { + "cx": { "type": "number" }, "cy": { "type": "number" }, "r": { "type": "number" }, + "fill": { "type": "string" }, "stroke": { "type": "string" }, "stroke-width": { "type": "number" } + } + }, + "path": { + "type": "object", + "properties": { + "d": { "type": "string", "description": "Absolute path data: M L Q C Z." }, + "fill": { "type": "string" }, "stroke": { "type": "string" }, "stroke-width": { "type": "number" } + } + } + } + } +``` + +Confirm comma placement so the JSON stays valid. If existing element definitions set +`additionalProperties: false`, mirror that on the new definitions for consistency. (The new shape +elements allow flex-item properties via the `allOf` ref, so do NOT set `additionalProperties: false` +on the box-shape elements unless the existing `separatorElement` does and still validates with the +ref — mirror `separatorElement` exactly.) + +- [ ] **Step 3: Validate the JSON parses** + +Run: `node -e "JSON.parse(require('fs').readFileSync('src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json','utf8')); console.log('valid')"` +Expected: prints `valid`. (If `node` is unavailable in the sandbox, instead run +`python3 -c "import json;json.load(open('src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json'));print('valid')"`.) + +- [ ] **Step 4: Autocomplete (only if needed)** + +`yaml-autocomplete.mjs` derives element types and properties from the schema's +`layout.items.$ref` and the element definitions. Open it and confirm it reads element types from the +schema dynamically (the `elementTypes` derivation near the top). If it does, the new schema entries +are picked up automatically and no change is needed — verify by reading the relevant lines. If it +hard-codes any element list or snippet map (e.g. the `element:` snippet object near the bottom), +add `rect`, `circle`, `ellipse`, `draw` there with minimal snippets, e.g.: + +```js + rect: { type: '0', width: '1', height: '2', fill: '3' }, + circle: { type: '0', size: '1', fill: '2' }, + ellipse: { type: '0', width: '1', height: '2', fill: '3' }, + draw: { type: '0', width: '1', height: '2', shapes: '3' }, +``` + +Only edit if a hard-coded list exists; otherwise leave the file unchanged and note "no change needed". + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs +git commit -m "docs(playground): add shape and draw schemas to template json" +``` + +(If `yaml-autocomplete.mjs` was not modified, omit it from the `git add`.) + +--- + +## Task 20: Docs — template SKILL.md (marketplace repo, conditional) + +**Files:** +- Modify (external repo, if present locally): `flexrender/skills/template/SKILL.md` + +Per AGENTS.md, the `template` skill lives in the separate `RoboNET/FlexRender-Marketplace` repo and +is NOT part of this checkout (confirmed: no `flexrender/skills/` directory exists here). + +- [ ] **Step 1: Check for a local skill file** + +Run: `test -f flexrender/skills/template/SKILL.md && echo present || echo absent` +Expected: `absent` (the skill is in the marketplace repo). + +- [ ] **Step 2: Record the follow-up if absent** + +If absent, no file change is made in this repo. Instead, ensure the PR description (Task 21) lists a +required follow-up: "Update `flexrender/skills/template/SKILL.md` in `RoboNET/FlexRender-Marketplace` +to document the new `rect`/`circle`/`ellipse`/`draw` elements, gradient fill object form, and the +`MaxShapesPerDraw` limit (Element Types + Common Properties sections)." + +If present (unexpected), add the new element types, gradient object form, and `draw` shape grammar to +the Element Types and Common Properties sections, then commit: + +```bash +git add flexrender/skills/template/SKILL.md +git commit -m "docs(skill): document shapes and draw in template skill" +``` + +- [ ] **Step 3: No-op confirmation** + +If absent, there is nothing to commit for this task. Proceed to Task 21. + +--- + +## Task 21: Final verification and PR + +**Files:** none (verification + PR). + +- [ ] **Step 1: Clean build** + +Run: `dotnet build FlexRender.slnx` +Expected: BUILD SUCCEEDED, zero warnings. + +- [ ] **Step 2: Full test suite** + +Run: `dotnet test FlexRender.slnx` +Expected: PASS, no regressions. + +- [ ] **Step 3: Push the branch** + +```bash +git push -u origin feature/charts-and-shapes +``` + +- [ ] **Step 4: Open the PR** + +```bash +gh pr create --base main --head feature/charts-and-shapes \ + --title "feat: shape primitives (rect, circle, ellipse, draw) — Phase 1" \ + --body "Implements Phase 1 (Shapes) of the charts-and-shapes design. + +## Added +- Box shapes \`rect\`, \`circle\`, \`ellipse\` (fill/gradient/stroke/opacity; rect radius; circle \`size\` shorthand). +- Gradient fill object form (linear/radial; colors; angle) converted to FlexRender's CSS gradient string. +- \`draw\` element with absolute-coordinate shapes: line, polyline, rect, circle, path (M/L/Q/C/Z, absolute only, hand-written tokenizer, no regex). +- \`ResourceLimits.MaxShapesPerDraw\` (default 1000). +- Parser + KnownProperties (typo suggestions), Skia rendering, layout, unit + snapshot tests. +- Docs: llms.txt, llms-full.txt, wiki Element/Visual references, Playground JSON schema. + +## Follow-up (separate repo) +- Update \`flexrender/skills/template/SKILL.md\` in RoboNET/FlexRender-Marketplace for the new elements, gradient object form, and MaxShapesPerDraw. + +## Out of scope +Charts (later phases), SVG backend for shapes. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` + +- [ ] **Step 5: Confirm CI** + +Run: `gh pr checks` (after CI starts) — confirm checks pass or report failures. + +--- + +## Self-Review + +**1. Spec coverage (Phase 1 scope):** +- `rect`/`circle`/`ellipse` box shapes (fill/gradient/stroke/opacity, rect radius, circle `size`): Tasks 4, 5, 6 (AST), 11 (parse), 13 (render), 15 (snapshot). Opacity is the inherited base property (Tasks reuse `TemplateElement.Opacity`); `stroke-width` parsed in Task 11. ✓ +- Gradient fill object form (linear/radial, colors, angle): Task 10 (converter) + Task 11 (wired into fill parsing) + Task 15 (gradient snapshot). ✓ +- `draw` element with line/polyline/rect/circle/path, absolute coords, hand-written path tokenizer (no regex): Tasks 3 (tokenizer), 7 (DTOs), 8 (element), 12 (parse), 14 (render), 15 (snapshot). ✓ +- `MaxShapesPerDraw` (default 1000): Task 1 (limit) + Task 12 (enforcement test + parser). ✓ +- Parser support + KnownProperties typo suggestions: Tasks 11, 12 (registry + `ShapeParserTests.Parse_Rect_UnknownProperty_SuggestsCorrection`). ✓ +- Skia rendering: Tasks 13, 14. ✓ +- Unit tests (path edge cases, parsing, validation, limits): Tasks 3, 10, 11, 12. Snapshot tests: Task 15. ✓ +- Docs (llms.txt, llms-full.txt, Element-Reference, Visual-Reference, SKILL.md, Playground schema + autocomplete): Tasks 17, 18, 19, 20. ✓ +- Error handling: malformed `path.d` names offending command (Task 3 tests + Task 12 wrap to `TemplateParseException`); shape-count overflow (Task 12). ✓ +- Charts / SVG backend explicitly out of scope. ✓ + +**2. Placeholder scan:** No "TBD"/"implement later"/"add validation"-style placeholders. Every code +step contains complete, compilable code. The few "verify exact signature" notes (LayoutEngine +entrypoint in Task 9; `SkiaRenderer` constructor in Tasks 13–14; schema branch shape in Task 19) +are explicit verification instructions with a concrete fallback (mirror `SnapshotTestBase` / mirror +`separatorElement`), not missing content. + +**3. Type consistency across tasks:** +- `ElementType.Rect/Circle/Ellipse/Draw` defined in Task 2, used in Tasks 4–8. +- `PathCommandKind`, `PathPoint`, `PathCommand`, `PathParseException`, `PathDataParser.Parse` defined + in Task 3, used in Tasks 7 (DTOs), 12 (parse), 14 (render). Names match exactly. +- `DrawShape`/`DrawLine`/`DrawPolyline`/`DrawRect`/`DrawCircle`/`DrawPath` field names defined in + Task 7 are consumed identically in Task 12 (construction) and Task 14 (rendering): `DrawLine(X1,Y1,X2,Y2,Stroke,StrokeWidth)`, + `DrawPolyline(Points,Stroke,StrokeWidth,Fill)`, `DrawRect(X,Y,Width,Height,Fill,Stroke,StrokeWidth,Radius)`, + `DrawCircle(Cx,Cy,R,Fill,Stroke,StrokeWidth)`, `DrawPath(Commands,Fill,Stroke,StrokeWidth)`. ✓ +- `RectElement.Fill/Stroke/StrokeWidth/Radius`, `CircleElement.Fill/Stroke/StrokeWidth`, + `EllipseElement.Fill/Stroke/StrokeWidth` defined in Tasks 4–6 and used in Tasks 11, 13. ✓ +- `ShapeParsers.ConvertGradientObjectToCss` (Task 10) used by `ParseFill` (Task 11). ✓ +- `ShapeRenderer.DrawRect/DrawCircle/DrawEllipse/DrawShapes` (Tasks 13–14) dispatched from + `RenderingEngine.DrawElement` with matching parameter lists. ✓ +- `LayoutBoxShapeElement` / `MeasureBoxShapeIntrinsic` (Task 9) names match between definition and + switch usage. ✓ + +**Domain checklist holds:** AOT-safe (no reflection/dynamic/Type.GetType; path parsing is a +hand-written tokenizer, no regex); all new concrete classes are `sealed`; DTOs are `sealed record`; +`PathPoint` is a `readonly record struct`; `ArgumentNullException.ThrowIfNull` guards on +`PathDataParser.Parse`, `DrawElement` ctor, and all `CloneWithSubstitution` overrides; new element +types use the existing switch-based dispatch in layout, intrinsic measurement, and rendering; all new +YAML properties registered in `KnownProperties.cs`; XML docs on all public API; `MaxShapesPerDraw` +added (never weakening existing limits). From 2a3a38fb7bd11ade77221b4a3118dd8bf27ac301 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Fri, 12 Jun 2026 14:06:24 +0400 Subject: [PATCH 004/119] feat(core): add MaxShapesPerDraw resource limit --- .../Configuration/ResourceLimits.cs | 17 ++++++++++ .../ResourceLimitsShapesTests.cs | 34 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/FlexRender.Tests/Configuration/ResourceLimitsShapesTests.cs diff --git a/src/FlexRender.Core/Configuration/ResourceLimits.cs b/src/FlexRender.Core/Configuration/ResourceLimits.cs index a971853..950c11c 100644 --- a/src/FlexRender.Core/Configuration/ResourceLimits.cs +++ b/src/FlexRender.Core/Configuration/ResourceLimits.cs @@ -12,6 +12,7 @@ public sealed class ResourceLimits private int _maxRenderDepth = 100; private int _maxImageSize = 10 * 1024 * 1024; private int _maxFlexLines = 1000; + private int _maxShapesPerDraw = 1000; /// /// Maximum allowed YAML template file size in bytes. /// @@ -102,4 +103,20 @@ public int MaxImageSize _maxImageSize = value; } } + + /// + /// Maximum number of shapes allowed in a single 'draw' element. + /// Prevents resource exhaustion from templates with a huge shape list. + /// + /// Default: 1000. + /// Thrown when value is zero or negative. + public int MaxShapesPerDraw + { + get => _maxShapesPerDraw; + set + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + _maxShapesPerDraw = value; + } + } } diff --git a/tests/FlexRender.Tests/Configuration/ResourceLimitsShapesTests.cs b/tests/FlexRender.Tests/Configuration/ResourceLimitsShapesTests.cs new file mode 100644 index 0000000..cd63dc6 --- /dev/null +++ b/tests/FlexRender.Tests/Configuration/ResourceLimitsShapesTests.cs @@ -0,0 +1,34 @@ +using System; +using FlexRender.Configuration; +using Xunit; + +namespace FlexRender.Tests.Configuration; + +/// +/// Tests for the limit. +/// +public sealed class ResourceLimitsShapesTests +{ + [Fact] + public void MaxShapesPerDraw_DefaultsTo1000() + { + var limits = new ResourceLimits(); + Assert.Equal(1000, limits.MaxShapesPerDraw); + } + + [Fact] + public void MaxShapesPerDraw_AcceptsPositiveValue() + { + var limits = new ResourceLimits { MaxShapesPerDraw = 50 }; + Assert.Equal(50, limits.MaxShapesPerDraw); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MaxShapesPerDraw_RejectsNonPositive(int value) + { + var limits = new ResourceLimits(); + Assert.Throws(() => limits.MaxShapesPerDraw = value); + } +} From 8b36b0e113bc28453ea9847735bdc963f656524b Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Fri, 12 Jun 2026 14:06:53 +0400 Subject: [PATCH 005/119] feat(ast): add Rect, Circle, Ellipse, Draw element types --- .../Parsing/Ast/TemplateElement.cs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/FlexRender.Core/Parsing/Ast/TemplateElement.cs b/src/FlexRender.Core/Parsing/Ast/TemplateElement.cs index d33184e..339a95e 100644 --- a/src/FlexRender.Core/Parsing/Ast/TemplateElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/TemplateElement.cs @@ -61,7 +61,27 @@ public enum ElementType /// /// A content element that expands formatted text into a subtree. /// - Content + Content, + + /// + /// A rectangle shape element (flex box drawn as a filled/stroked rect). + /// + Rect, + + /// + /// A circle shape element (flex box drawn as a filled/stroked circle). + /// + Circle, + + /// + /// An ellipse shape element (flex box drawn as a filled/stroked ellipse). + /// + Ellipse, + + /// + /// A free-form drawing element holding absolute-coordinate shapes. + /// + Draw } /// From f01d447c0fba398dc6da44348a8dbf53181b27e5 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Fri, 12 Jun 2026 14:10:03 +0400 Subject: [PATCH 006/119] test(core): cover MaxShapesPerDraw default in consolidated limits test --- tests/FlexRender.Tests/Configuration/ResourceLimitsTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/FlexRender.Tests/Configuration/ResourceLimitsTests.cs b/tests/FlexRender.Tests/Configuration/ResourceLimitsTests.cs index 3831a67..8acf8fb 100644 --- a/tests/FlexRender.Tests/Configuration/ResourceLimitsTests.cs +++ b/tests/FlexRender.Tests/Configuration/ResourceLimitsTests.cs @@ -370,5 +370,6 @@ public void DefaultLimits_MatchOriginalHardcodedValues() Assert.Equal(100, limits.MaxTemplateNestingDepth); Assert.Equal(100, limits.MaxRenderDepth); Assert.Equal(10 * 1024 * 1024, limits.MaxImageSize); + Assert.Equal(1000, limits.MaxShapesPerDraw); } } From 67f9f521a7fababb1da272a3501390c48f75a60f Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Fri, 12 Jun 2026 14:13:21 +0400 Subject: [PATCH 007/119] feat(parser): add hand-written absolute-only path data tokenizer --- src/FlexRender.Core/Parsing/PathDataParser.cs | 218 ++++++++++++++++++ .../Parsing/PathDataParserTests.cs | 129 +++++++++++ 2 files changed, 347 insertions(+) create mode 100644 src/FlexRender.Core/Parsing/PathDataParser.cs create mode 100644 tests/FlexRender.Tests/Parsing/PathDataParserTests.cs diff --git a/src/FlexRender.Core/Parsing/PathDataParser.cs b/src/FlexRender.Core/Parsing/PathDataParser.cs new file mode 100644 index 0000000..8369944 --- /dev/null +++ b/src/FlexRender.Core/Parsing/PathDataParser.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace FlexRender.Parsing; + +/// +/// The kind of a parsed path command. +/// +public enum PathCommandKind +{ + /// Move the pen to a point (M). + MoveTo, + + /// Draw a straight line to a point (L). + LineTo, + + /// Draw a quadratic Bézier curve (Q): one control point, one end point. + QuadTo, + + /// Draw a cubic Bézier curve (C): two control points, one end point. + CubicTo, + + /// Close the current sub-path (Z). + Close +} + +/// +/// A 2D point in absolute path coordinates. +/// +/// The X coordinate. +/// The Y coordinate. +public readonly record struct PathPoint(float X, float Y); + +/// +/// A single parsed path command with its associated points. +/// +/// The command kind. +/// The command's points (empty for ). +public sealed record PathCommand(PathCommandKind Kind, IReadOnlyList Points); + +/// +/// Thrown when path data ('d' attribute) cannot be parsed. +/// +public sealed class PathParseException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message. + public PathParseException(string message) : base(message) + { + } +} + +/// +/// Hand-written tokenizer for SVG-style path data restricted to absolute commands +/// M, L, Q, C, Z. AOT-safe — no regex, no backtracking. +/// +/// +/// Lowercase command letters are accepted but treated as absolute (the spec restricts +/// drawing to absolute coordinates). Numbers may be separated by whitespace and/or commas. +/// Implicit repeated commands are supported per SVG semantics (e.g. "L 1 1 2 2" is two +/// line-to commands). +/// +public static class PathDataParser +{ + /// + /// Parses path data into an ordered list of absolute commands. + /// + /// The path data string (e.g. "M 0 0 L 100 50 Z"). + /// The parsed commands in order. Empty when is blank. + /// Thrown when is null. + /// Thrown on malformed input, naming the command and position. + public static IReadOnlyList Parse(string data) + { + ArgumentNullException.ThrowIfNull(data); + + var commands = new List(); + var i = 0; + var length = data.Length; + char currentCommand = '\0'; + + while (i < length) + { + SkipSeparators(data, ref i); + if (i >= length) + break; + + var c = data[i]; + var upper = char.ToUpperInvariant(c); + + if (upper is 'M' or 'L' or 'Q' or 'C' or 'Z') + { + currentCommand = upper; + i++; + + if (currentCommand == 'Z') + { + commands.Add(new PathCommand(PathCommandKind.Close, Array.Empty())); + currentCommand = '\0'; + } + continue; + } + + // Not a command letter: must be a coordinate continuing the current command. + if (currentCommand == '\0') + { + throw new PathParseException( + $"Unexpected character '{c}' at position {i}: path data must begin with a command letter (M, L, Q, C, Z)."); + } + + if (!IsCoordinateStart(c)) + { + throw new PathParseException( + $"Unexpected character '{c}' at position {i} while reading command '{currentCommand}'."); + } + + var (kind, pointCount) = currentCommand switch + { + 'M' => (PathCommandKind.MoveTo, 1), + 'L' => (PathCommandKind.LineTo, 1), + 'Q' => (PathCommandKind.QuadTo, 2), + 'C' => (PathCommandKind.CubicTo, 3), + _ => throw new PathParseException( + $"Internal error: unexpected command '{currentCommand}' at position {i}.") + }; + + var points = new PathPoint[pointCount]; + for (var p = 0; p < pointCount; p++) + { + var x = ReadNumber(data, ref i, currentCommand); + var y = ReadNumber(data, ref i, currentCommand); + points[p] = new PathPoint(x, y); + } + + commands.Add(new PathCommand(kind, points)); + + // After an initial MoveTo, repeated coordinates imply LineTo (SVG semantics). + if (currentCommand == 'M') + { + currentCommand = 'L'; + } + } + + return commands; + } + + private static bool IsCoordinateStart(char c) + => c is '-' or '+' or '.' || (c >= '0' && c <= '9'); + + private static void SkipSeparators(string data, ref int i) + { + while (i < data.Length) + { + var c = data[i]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == ',') + { + i++; + continue; + } + break; + } + } + + private static float ReadNumber(string data, ref int i, char command) + { + SkipSeparators(data, ref i); + + var start = i; + var length = data.Length; + + if (i < length && (data[i] == '-' || data[i] == '+')) + i++; + + var hasDigits = false; + while (i < length && data[i] >= '0' && data[i] <= '9') + { + i++; + hasDigits = true; + } + + if (i < length && data[i] == '.') + { + i++; + while (i < length && data[i] >= '0' && data[i] <= '9') + { + i++; + hasDigits = true; + } + } + + // Exponent (e.g. 1e3, 2.5E-2) + if (i < length && (data[i] == 'e' || data[i] == 'E')) + { + i++; + if (i < length && (data[i] == '-' || data[i] == '+')) + i++; + while (i < length && data[i] >= '0' && data[i] <= '9') + i++; + } + + if (!hasDigits) + { + throw new PathParseException( + $"Expected a number at position {start} while reading command '{command}'."); + } + + var span = data.AsSpan(start, i - start); + if (!float.TryParse(span, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) + { + throw new PathParseException( + $"Invalid number '{span.ToString()}' at position {start} while reading command '{command}'."); + } + + return value; + } +} diff --git a/tests/FlexRender.Tests/Parsing/PathDataParserTests.cs b/tests/FlexRender.Tests/Parsing/PathDataParserTests.cs new file mode 100644 index 0000000..35f0e7a --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/PathDataParserTests.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using FlexRender.Parsing; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Edge-case tests for the hand-written absolute-only SVG-style path tokenizer. +/// +public sealed class PathDataParserTests +{ + [Fact] + public void Parse_MoveAndLine_ProducesTwoCommands() + { + var commands = PathDataParser.Parse("M 0 0 L 100 50"); + + Assert.Equal(2, commands.Count); + Assert.Equal(PathCommandKind.MoveTo, commands[0].Kind); + Assert.Equal(0f, commands[0].Points[0].X); + Assert.Equal(0f, commands[0].Points[0].Y); + Assert.Equal(PathCommandKind.LineTo, commands[1].Kind); + Assert.Equal(100f, commands[1].Points[0].X); + Assert.Equal(50f, commands[1].Points[0].Y); + } + + [Fact] + public void Parse_QuadraticAndCubicAndClose_ProducesAllCommands() + { + var commands = PathDataParser.Parse("M 0 0 Q 150 0 200 50 C 10 20 30 40 50 60 Z"); + + Assert.Equal(4, commands.Count); + Assert.Equal(PathCommandKind.MoveTo, commands[0].Kind); + Assert.Equal(PathCommandKind.QuadTo, commands[1].Kind); + Assert.Equal(2, commands[1].Points.Count); + Assert.Equal(PathCommandKind.CubicTo, commands[2].Kind); + Assert.Equal(3, commands[2].Points.Count); + Assert.Equal(PathCommandKind.Close, commands[3].Kind); + Assert.Empty(commands[3].Points); + } + + [Fact] + public void Parse_CommaSeparatedCoordinates_ParsesCorrectly() + { + var commands = PathDataParser.Parse("M0,0 L100,50"); + + Assert.Equal(2, commands.Count); + Assert.Equal(100f, commands[1].Points[0].X); + Assert.Equal(50f, commands[1].Points[0].Y); + } + + [Fact] + public void Parse_NegativeAndDecimalCoordinates_ParsesCorrectly() + { + var commands = PathDataParser.Parse("M -1.5 -2.25 L 3.0 -4"); + + Assert.Equal(-1.5f, commands[0].Points[0].X); + Assert.Equal(-2.25f, commands[0].Points[0].Y); + Assert.Equal(3.0f, commands[1].Points[0].X); + Assert.Equal(-4f, commands[1].Points[0].Y); + } + + [Fact] + public void Parse_ImplicitRepeatedLineTo_AfterSingleCommandLetter() + { + // SVG semantics: "L 10 10 20 20" means two LineTo commands. + var commands = PathDataParser.Parse("M 0 0 L 10 10 20 20"); + + Assert.Equal(3, commands.Count); + Assert.Equal(PathCommandKind.LineTo, commands[1].Kind); + Assert.Equal(10f, commands[1].Points[0].X); + Assert.Equal(PathCommandKind.LineTo, commands[2].Kind); + Assert.Equal(20f, commands[2].Points[0].X); + Assert.Equal(20f, commands[2].Points[0].Y); + } + + [Fact] + public void Parse_LowercaseCommands_TreatedAsAbsolute() + { + // Lowercase (relative) letters are accepted but treated as absolute, + // matching the spec's "absolute only" constraint without erroring on case. + var commands = PathDataParser.Parse("m 0 0 l 100 50"); + + Assert.Equal(PathCommandKind.MoveTo, commands[0].Kind); + Assert.Equal(PathCommandKind.LineTo, commands[1].Kind); + Assert.Equal(100f, commands[1].Points[0].X); + } + + [Fact] + public void Parse_ExtraWhitespace_Ignored() + { + var commands = PathDataParser.Parse(" M 0 0\tL\n100 50 "); + Assert.Equal(2, commands.Count); + } + + [Fact] + public void Parse_EmptyString_ReturnsEmptyList() + { + Assert.Empty(PathDataParser.Parse("")); + Assert.Empty(PathDataParser.Parse(" ")); + } + + [Fact] + public void Parse_UnknownCommand_ThrowsWithCommandAndPosition() + { + var ex = Assert.Throws(() => PathDataParser.Parse("M 0 0 X 1 1")); + Assert.Contains("'X'", ex.Message); + Assert.Contains("position", ex.Message); + } + + [Fact] + public void Parse_MissingCoordinate_ThrowsWithCommand() + { + var ex = Assert.Throws(() => PathDataParser.Parse("M 0 0 L 10")); + Assert.Contains("'L'", ex.Message); + } + + [Fact] + public void Parse_DataBeforeFirstCommand_Throws() + { + var ex = Assert.Throws(() => PathDataParser.Parse("10 20 L 30 40")); + Assert.Contains("position", ex.Message); + } + + [Fact] + public void Parse_NullArgument_Throws() + { + Assert.Throws(() => PathDataParser.Parse(null!)); + } +} From d2913ddd3d3d2a5b649d6d78c378559e8ec3eb11 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 13:19:05 +0400 Subject: [PATCH 008/119] fix(parser): reject non-finite, relative, and unbounded path data --- src/FlexRender.Core/Parsing/PathDataParser.cs | 33 ++++++++-- .../Parsing/PathDataParserTests.cs | 62 ++++++++++++++++--- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/FlexRender.Core/Parsing/PathDataParser.cs b/src/FlexRender.Core/Parsing/PathDataParser.cs index 8369944..4d0265c 100644 --- a/src/FlexRender.Core/Parsing/PathDataParser.cs +++ b/src/FlexRender.Core/Parsing/PathDataParser.cs @@ -58,8 +58,8 @@ public PathParseException(string message) : base(message) /// M, L, Q, C, Z. AOT-safe — no regex, no backtracking. /// /// -/// Lowercase command letters are accepted but treated as absolute (the spec restricts -/// drawing to absolute coordinates). Numbers may be separated by whitespace and/or commas. +/// Only absolute commands are accepted; lowercase (relative) commands are rejected with an error. +/// Numbers may be separated by whitespace and/or commas. /// Implicit repeated commands are supported per SVG semantics (e.g. "L 1 1 2 2" is two /// line-to commands). /// @@ -69,10 +69,14 @@ public static class PathDataParser /// Parses path data into an ordered list of absolute commands. /// /// The path data string (e.g. "M 0 0 L 100 50 Z"). + /// The maximum number of commands to accept before failing, guarding against unbounded input. /// The parsed commands in order. Empty when is blank. /// Thrown when is null. - /// Thrown on malformed input, naming the command and position. - public static IReadOnlyList Parse(string data) + /// + /// Thrown on malformed input, naming the command and position, or when the number of commands + /// exceeds . + /// + public static IReadOnlyList Parse(string data, int maxCommands = 10000) { ArgumentNullException.ThrowIfNull(data); @@ -92,12 +96,22 @@ public static IReadOnlyList Parse(string data) if (upper is 'M' or 'L' or 'Q' or 'C' or 'Z') { + if (c != upper) + { + throw new PathParseException( + $"Relative path command '{c}' at position {i} is not supported; use absolute commands M, L, Q, C, Z."); + } currentCommand = upper; i++; if (currentCommand == 'Z') { commands.Add(new PathCommand(PathCommandKind.Close, Array.Empty())); + if (commands.Count > maxCommands) + { + throw new PathParseException( + $"Path data exceeds the maximum of {maxCommands} commands."); + } currentCommand = '\0'; } continue; @@ -135,6 +149,11 @@ public static IReadOnlyList Parse(string data) } commands.Add(new PathCommand(kind, points)); + if (commands.Count > maxCommands) + { + throw new PathParseException( + $"Path data exceeds the maximum of {maxCommands} commands."); + } // After an initial MoveTo, repeated coordinates imply LineTo (SVG semantics). if (currentCommand == 'M') @@ -213,6 +232,12 @@ private static float ReadNumber(string data, ref int i, char command) $"Invalid number '{span.ToString()}' at position {start} while reading command '{command}'."); } + if (!float.IsFinite(value)) + { + throw new PathParseException( + $"Number '{span.ToString()}' at position {start} is not finite while reading command '{command}'."); + } + return value; } } diff --git a/tests/FlexRender.Tests/Parsing/PathDataParserTests.cs b/tests/FlexRender.Tests/Parsing/PathDataParserTests.cs index 35f0e7a..53277db 100644 --- a/tests/FlexRender.Tests/Parsing/PathDataParserTests.cs +++ b/tests/FlexRender.Tests/Parsing/PathDataParserTests.cs @@ -74,15 +74,11 @@ public void Parse_ImplicitRepeatedLineTo_AfterSingleCommandLetter() } [Fact] - public void Parse_LowercaseCommands_TreatedAsAbsolute() + public void Parse_LowercaseCommand_Throws() { - // Lowercase (relative) letters are accepted but treated as absolute, - // matching the spec's "absolute only" constraint without erroring on case. - var commands = PathDataParser.Parse("m 0 0 l 100 50"); - - Assert.Equal(PathCommandKind.MoveTo, commands[0].Kind); - Assert.Equal(PathCommandKind.LineTo, commands[1].Kind); - Assert.Equal(100f, commands[1].Points[0].X); + var ex = Assert.Throws(() => PathDataParser.Parse("m 0 0 l 100 50")); + Assert.Contains("Relative", ex.Message); + Assert.Contains("'m'", ex.Message); } [Fact] @@ -126,4 +122,54 @@ public void Parse_NullArgument_Throws() { Assert.Throws(() => PathDataParser.Parse(null!)); } + + [Fact] + public void Parse_OverflowToInfinity_Throws() + { + var ex = Assert.Throws(() => PathDataParser.Parse("M 1e999 0")); + Assert.Contains("not finite", ex.Message); + } + + [Fact] + public void Parse_DegenerateExponent_Throws() + { + Assert.Throws(() => PathDataParser.Parse("M 1e 0")); + Assert.Throws(() => PathDataParser.Parse("M 1e+ 0")); + } + + [Fact] + public void Parse_LeadingDotCoordinate_ParsesAsFraction() + { + var commands = PathDataParser.Parse("M .5 .5"); + Assert.Equal(0.5f, commands[0].Points[0].X); + Assert.Equal(0.5f, commands[0].Points[0].Y); + } + + [Fact] + public void Parse_BareSign_Throws() + { + Assert.Throws(() => PathDataParser.Parse("M + 0")); + } + + [Fact] + public void Parse_PlusSignedCoordinates_Parse() + { + var commands = PathDataParser.Parse("M +1 +2"); + Assert.Equal(1f, commands[0].Points[0].X); + Assert.Equal(2f, commands[0].Points[0].Y); + } + + [Fact] + public void Parse_CoordinateAfterClose_Throws() + { + var ex = Assert.Throws(() => PathDataParser.Parse("M 0 0 Z 5 5")); + Assert.Contains("begin with a command letter", ex.Message); + } + + [Fact] + public void Parse_ExceedsMaxCommands_Throws() + { + var ex = Assert.Throws(() => PathDataParser.Parse("M 0 0 L 1 1 L 2 2", maxCommands: 2)); + Assert.Contains("maximum", ex.Message); + } } From c0bdcfa31578a82a1825abc87884894830c8c1e7 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 13:22:22 +0400 Subject: [PATCH 009/119] feat(ast): add RectElement shape --- .../Parsing/Ast/RectElement.cs | 66 +++++++++++++++++++ .../Parsing/Ast/RectElementTests.cs | 50 ++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/FlexRender.Core/Parsing/Ast/RectElement.cs create mode 100644 tests/FlexRender.Tests/Parsing/Ast/RectElementTests.cs diff --git a/src/FlexRender.Core/Parsing/Ast/RectElement.cs b/src/FlexRender.Core/Parsing/Ast/RectElement.cs new file mode 100644 index 0000000..023bdf3 --- /dev/null +++ b/src/FlexRender.Core/Parsing/Ast/RectElement.cs @@ -0,0 +1,66 @@ +using System; +using FlexRender.TemplateEngine; + +namespace FlexRender.Parsing.Ast; + +/// +/// A rectangle shape element. Participates in flex layout as a box with explicit +/// width/height and is drawn as a filled and/or stroked rectangle with optional rounded corners. +/// +public sealed class RectElement : TemplateElement +{ + /// + public override ElementType Type => ElementType.Rect; + + /// + /// Fill: a solid color (e.g. "#4A90D9") or a gradient string produced from the YAML + /// gradient object form (a CSS-style "linear-gradient(...)" / "radial-gradient(...)"). + /// Null means no fill. + /// + public ExprValue Fill { get; set; } + + /// Stroke color in hex format. Null means no stroke. + public ExprValue Stroke { get; set; } + + /// Stroke width in pixels. Zero means no stroke. + public ExprValue StrokeWidth { get; set; } + + /// Corner radius (px, em). Null means square corners. + public ExprValue Radius { get; set; } + + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new RectElement + { + Fill = substitutor(Fill.Value)!, + Stroke = Stroke, + StrokeWidth = StrokeWidth, + Radius = Radius + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + + /// + public override void ResolveExpressions(Func resolver, ObjectValue data) + { + base.ResolveExpressions(resolver, data); + Fill = Fill.Resolve(resolver, data); + Stroke = Stroke.Resolve(resolver, data); + StrokeWidth = StrokeWidth.Resolve(resolver, data); + Radius = Radius.Resolve(resolver, data); + } + + /// + public override void Materialize() + { + base.Materialize(); + Fill = Fill.Materialize(nameof(Fill)); + Stroke = Stroke.Materialize(nameof(Stroke), ValueKind.Color); + StrokeWidth = StrokeWidth.Materialize(nameof(StrokeWidth)); + Radius = Radius.Materialize(nameof(Radius), ValueKind.Size); + } +} diff --git a/tests/FlexRender.Tests/Parsing/Ast/RectElementTests.cs b/tests/FlexRender.Tests/Parsing/Ast/RectElementTests.cs new file mode 100644 index 0000000..dd75484 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Ast/RectElementTests.cs @@ -0,0 +1,50 @@ +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the AST class. +/// +public sealed class RectElementTests +{ + [Fact] + public void Type_IsRect() + { + var rect = new RectElement(); + Assert.Equal(ElementType.Rect, rect.Type); + } + + [Fact] + public void Defaults_AreEmpty() + { + var rect = new RectElement(); + Assert.Null(rect.Fill.Value); + Assert.Null(rect.Stroke.Value); + Assert.Equal(0f, rect.StrokeWidth.Value); + Assert.Null(rect.Radius.Value); + } + + [Fact] + public void CloneWithSubstitution_CopiesShapeProperties() + { + var rect = new RectElement + { + Fill = "#4A90D9", + Stroke = "#333333", + StrokeWidth = 2f, + Radius = "4", + Width = "100", + Height = "50" + }; + + var clone = (RectElement)rect.CloneWithSubstitution(s => s); + + Assert.Equal("#4A90D9", clone.Fill.Value); + Assert.Equal("#333333", clone.Stroke.Value); + Assert.Equal(2f, clone.StrokeWidth.Value); + Assert.Equal("4", clone.Radius.Value); + Assert.Equal("100", clone.Width.Value); + Assert.Equal("50", clone.Height.Value); + } +} From 44e7b201e5b1ff48d9117c9ebfe6ec91d491d68e Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 13:23:17 +0400 Subject: [PATCH 010/119] feat(ast): add CircleElement shape --- .../Parsing/Ast/CircleElement.cs | 61 +++++++++++++++++++ .../Parsing/Ast/CircleElementTests.cs | 38 ++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/FlexRender.Core/Parsing/Ast/CircleElement.cs create mode 100644 tests/FlexRender.Tests/Parsing/Ast/CircleElementTests.cs diff --git a/src/FlexRender.Core/Parsing/Ast/CircleElement.cs b/src/FlexRender.Core/Parsing/Ast/CircleElement.cs new file mode 100644 index 0000000..eaaaf2c --- /dev/null +++ b/src/FlexRender.Core/Parsing/Ast/CircleElement.cs @@ -0,0 +1,61 @@ +using System; +using FlexRender.TemplateEngine; + +namespace FlexRender.Parsing.Ast; + +/// +/// A circle shape element. Participates in flex layout as a square box (the YAML 'size' shorthand +/// sets both Width and Height) and is drawn as a filled and/or stroked circle inscribed in the box. +/// When Width and Height differ, the smaller dimension is used as the diameter. +/// +public sealed class CircleElement : TemplateElement +{ + /// + public override ElementType Type => ElementType.Circle; + + /// + /// Fill: a solid color (e.g. "#4A90D9") or a gradient string produced from the YAML + /// gradient object form (a CSS-style "linear-gradient(...)" / "radial-gradient(...)"). + /// Null means no fill. + /// + public ExprValue Fill { get; set; } + + /// Stroke color in hex format. Null means no stroke. + public ExprValue Stroke { get; set; } + + /// Stroke width in pixels. Zero means no stroke. + public ExprValue StrokeWidth { get; set; } + + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new CircleElement + { + Fill = substitutor(Fill.Value)!, + Stroke = Stroke, + StrokeWidth = StrokeWidth + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + + /// + public override void ResolveExpressions(Func resolver, ObjectValue data) + { + base.ResolveExpressions(resolver, data); + Fill = Fill.Resolve(resolver, data); + Stroke = Stroke.Resolve(resolver, data); + StrokeWidth = StrokeWidth.Resolve(resolver, data); + } + + /// + public override void Materialize() + { + base.Materialize(); + Fill = Fill.Materialize(nameof(Fill)); + Stroke = Stroke.Materialize(nameof(Stroke), ValueKind.Color); + StrokeWidth = StrokeWidth.Materialize(nameof(StrokeWidth)); + } +} diff --git a/tests/FlexRender.Tests/Parsing/Ast/CircleElementTests.cs b/tests/FlexRender.Tests/Parsing/Ast/CircleElementTests.cs new file mode 100644 index 0000000..bdc5adc --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Ast/CircleElementTests.cs @@ -0,0 +1,38 @@ +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the AST class. +/// +public sealed class CircleElementTests +{ + [Fact] + public void Type_IsCircle() + { + var circle = new CircleElement(); + Assert.Equal(ElementType.Circle, circle.Type); + } + + [Fact] + public void CloneWithSubstitution_CopiesShapeProperties() + { + var circle = new CircleElement + { + Fill = "#e74c3c", + Stroke = "#000000", + StrokeWidth = 1.5f, + Width = "40", + Height = "40" + }; + + var clone = (CircleElement)circle.CloneWithSubstitution(s => s); + + Assert.Equal("#e74c3c", clone.Fill.Value); + Assert.Equal("#000000", clone.Stroke.Value); + Assert.Equal(1.5f, clone.StrokeWidth.Value); + Assert.Equal("40", clone.Width.Value); + Assert.Equal("40", clone.Height.Value); + } +} From 706a24f0b0e60bdf67c1d682dfaa8c3362d4a3a3 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 13:24:14 +0400 Subject: [PATCH 011/119] feat(ast): add EllipseElement shape --- .../Parsing/Ast/EllipseElement.cs | 60 +++++++++++++++++++ .../Parsing/Ast/EllipseElementTests.cs | 38 ++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/FlexRender.Core/Parsing/Ast/EllipseElement.cs create mode 100644 tests/FlexRender.Tests/Parsing/Ast/EllipseElementTests.cs diff --git a/src/FlexRender.Core/Parsing/Ast/EllipseElement.cs b/src/FlexRender.Core/Parsing/Ast/EllipseElement.cs new file mode 100644 index 0000000..8be8603 --- /dev/null +++ b/src/FlexRender.Core/Parsing/Ast/EllipseElement.cs @@ -0,0 +1,60 @@ +using System; +using FlexRender.TemplateEngine; + +namespace FlexRender.Parsing.Ast; + +/// +/// An ellipse shape element. Participates in flex layout as a box with explicit width/height +/// and is drawn as a filled and/or stroked ellipse inscribed in the box. +/// +public sealed class EllipseElement : TemplateElement +{ + /// + public override ElementType Type => ElementType.Ellipse; + + /// + /// Fill: a solid color (e.g. "#4A90D9") or a gradient string produced from the YAML + /// gradient object form (a CSS-style "linear-gradient(...)" / "radial-gradient(...)"). + /// Null means no fill. + /// + public ExprValue Fill { get; set; } + + /// Stroke color in hex format. Null means no stroke. + public ExprValue Stroke { get; set; } + + /// Stroke width in pixels. Zero means no stroke. + public ExprValue StrokeWidth { get; set; } + + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new EllipseElement + { + Fill = substitutor(Fill.Value)!, + Stroke = Stroke, + StrokeWidth = StrokeWidth + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + + /// + public override void ResolveExpressions(Func resolver, ObjectValue data) + { + base.ResolveExpressions(resolver, data); + Fill = Fill.Resolve(resolver, data); + Stroke = Stroke.Resolve(resolver, data); + StrokeWidth = StrokeWidth.Resolve(resolver, data); + } + + /// + public override void Materialize() + { + base.Materialize(); + Fill = Fill.Materialize(nameof(Fill)); + Stroke = Stroke.Materialize(nameof(Stroke), ValueKind.Color); + StrokeWidth = StrokeWidth.Materialize(nameof(StrokeWidth)); + } +} diff --git a/tests/FlexRender.Tests/Parsing/Ast/EllipseElementTests.cs b/tests/FlexRender.Tests/Parsing/Ast/EllipseElementTests.cs new file mode 100644 index 0000000..77e6195 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Ast/EllipseElementTests.cs @@ -0,0 +1,38 @@ +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the AST class. +/// +public sealed class EllipseElementTests +{ + [Fact] + public void Type_IsEllipse() + { + var ellipse = new EllipseElement(); + Assert.Equal(ElementType.Ellipse, ellipse.Type); + } + + [Fact] + public void CloneWithSubstitution_CopiesShapeProperties() + { + var ellipse = new EllipseElement + { + Fill = "#2ecc71", + Stroke = "#111111", + StrokeWidth = 3f, + Width = "120", + Height = "60" + }; + + var clone = (EllipseElement)ellipse.CloneWithSubstitution(s => s); + + Assert.Equal("#2ecc71", clone.Fill.Value); + Assert.Equal("#111111", clone.Stroke.Value); + Assert.Equal(3f, clone.StrokeWidth.Value); + Assert.Equal("120", clone.Width.Value); + Assert.Equal("60", clone.Height.Value); + } +} From 84223f6042773bd513ce142cf4f04b08f4dd27ed Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 13:31:59 +0400 Subject: [PATCH 012/119] feat(ast): add draw shape DTOs --- src/FlexRender.Core/Parsing/Ast/DrawShapes.cs | 216 ++++++++++++++++++ .../Parsing/Ast/DrawShapesTests.cs | 62 +++++ 2 files changed, 278 insertions(+) create mode 100644 src/FlexRender.Core/Parsing/Ast/DrawShapes.cs create mode 100644 tests/FlexRender.Tests/Parsing/Ast/DrawShapesTests.cs diff --git a/src/FlexRender.Core/Parsing/Ast/DrawShapes.cs b/src/FlexRender.Core/Parsing/Ast/DrawShapes.cs new file mode 100644 index 0000000..e455a49 --- /dev/null +++ b/src/FlexRender.Core/Parsing/Ast/DrawShapes.cs @@ -0,0 +1,216 @@ +using System.Collections.Generic; + +namespace FlexRender.Parsing.Ast; + +/// +/// Base type for a single shape inside a draw element. +/// All coordinates are absolute, relative to the draw element's top-left corner. +/// +public abstract record DrawShape; + +/// +/// A straight line segment. +/// +public sealed record DrawLine : DrawShape +{ + /// + /// Initializes a new instance of the record. + /// + /// Start X. + /// Start Y. + /// End X. + /// End Y. + /// Stroke color (hex). Null means default black. + /// Stroke width in pixels. + public DrawLine(float x1, float y1, float x2, float y2, string? stroke, float strokeWidth) + { + X1 = x1; + Y1 = y1; + X2 = x2; + Y2 = y2; + Stroke = stroke; + StrokeWidth = strokeWidth; + } + + /// Start X. + public float X1 { get; init; } + + /// Start Y. + public float Y1 { get; init; } + + /// End X. + public float X2 { get; init; } + + /// End Y. + public float Y2 { get; init; } + + /// Stroke color (hex). Null means default black. + public string? Stroke { get; init; } + + /// Stroke width in pixels. + public float StrokeWidth { get; init; } +} + +/// +/// A connected sequence of line segments through the given points. +/// +public sealed record DrawPolyline : DrawShape +{ + /// + /// Initializes a new instance of the record. + /// + /// The vertices in order. + /// Stroke color (hex). Null means default black. + /// Stroke width in pixels. + /// Optional fill color (hex) for the enclosed area. Null means no fill. + public DrawPolyline(IReadOnlyList points, string? stroke, float strokeWidth, string? fill) + { + Points = points; + Stroke = stroke; + StrokeWidth = strokeWidth; + Fill = fill; + } + + /// The vertices in order. + public IReadOnlyList Points { get; init; } + + /// Stroke color (hex). Null means default black. + public string? Stroke { get; init; } + + /// Stroke width in pixels. + public float StrokeWidth { get; init; } + + /// Optional fill color (hex) for the enclosed area. Null means no fill. + public string? Fill { get; init; } +} + +/// +/// A rectangle, optionally rounded. +/// +public sealed record DrawRect : DrawShape +{ + /// + /// Initializes a new instance of the record. + /// + /// Top-left X. + /// Top-left Y. + /// Width in pixels. + /// Height in pixels. + /// Optional fill color (hex). Null means no fill. + /// Optional stroke color (hex). Null means no stroke. + /// Stroke width in pixels. + /// Corner radius in pixels. + public DrawRect( + float x, float y, float width, float height, + string? fill, string? stroke, float strokeWidth, float radius) + { + X = x; + Y = y; + Width = width; + Height = height; + Fill = fill; + Stroke = stroke; + StrokeWidth = strokeWidth; + Radius = radius; + } + + /// Top-left X. + public float X { get; init; } + + /// Top-left Y. + public float Y { get; init; } + + /// Width in pixels. + public float Width { get; init; } + + /// Height in pixels. + public float Height { get; init; } + + /// Optional fill color (hex). Null means no fill. + public string? Fill { get; init; } + + /// Optional stroke color (hex). Null means no stroke. + public string? Stroke { get; init; } + + /// Stroke width in pixels. + public float StrokeWidth { get; init; } + + /// Corner radius in pixels. + public float Radius { get; init; } +} + +/// +/// A circle centred at (Cx, Cy) with radius R. +/// +public sealed record DrawCircle : DrawShape +{ + /// + /// Initializes a new instance of the record. + /// + /// Centre X. + /// Centre Y. + /// Radius in pixels. + /// Optional fill color (hex). Null means no fill. + /// Optional stroke color (hex). Null means no stroke. + /// Stroke width in pixels. + public DrawCircle(float cx, float cy, float r, string? fill, string? stroke, float strokeWidth) + { + Cx = cx; + Cy = cy; + R = r; + Fill = fill; + Stroke = stroke; + StrokeWidth = strokeWidth; + } + + /// Centre X. + public float Cx { get; init; } + + /// Centre Y. + public float Cy { get; init; } + + /// Radius in pixels. + public float R { get; init; } + + /// Optional fill color (hex). Null means no fill. + public string? Fill { get; init; } + + /// Optional stroke color (hex). Null means no stroke. + public string? Stroke { get; init; } + + /// Stroke width in pixels. + public float StrokeWidth { get; init; } +} + +/// +/// A free-form path made of pre-parsed absolute commands. +/// +public sealed record DrawPath : DrawShape +{ + /// + /// Initializes a new instance of the record. + /// + /// The parsed path commands (M/L/Q/C/Z). + /// Optional fill color (hex). Null means no fill. + /// Optional stroke color (hex). Null means no stroke. + /// Stroke width in pixels. + public DrawPath(IReadOnlyList commands, string? fill, string? stroke, float strokeWidth) + { + Commands = commands; + Fill = fill; + Stroke = stroke; + StrokeWidth = strokeWidth; + } + + /// The parsed path commands (M/L/Q/C/Z). + public IReadOnlyList Commands { get; init; } + + /// Optional fill color (hex). Null means no fill. + public string? Fill { get; init; } + + /// Optional stroke color (hex). Null means no stroke. + public string? Stroke { get; init; } + + /// Stroke width in pixels. + public float StrokeWidth { get; init; } +} diff --git a/tests/FlexRender.Tests/Parsing/Ast/DrawShapesTests.cs b/tests/FlexRender.Tests/Parsing/Ast/DrawShapesTests.cs new file mode 100644 index 0000000..f49493a --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Ast/DrawShapesTests.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the immutable draw-shape DTOs. +/// +public sealed class DrawShapesTests +{ + [Fact] + public void DrawLine_StoresCoordinatesAndStroke() + { + var line = new DrawLine(0f, 100f, 400f, 50f, "#333333", 2f); + Assert.Equal(0f, line.X1); + Assert.Equal(100f, line.Y1); + Assert.Equal(400f, line.X2); + Assert.Equal(50f, line.Y2); + Assert.Equal("#333333", line.Stroke); + Assert.Equal(2f, line.StrokeWidth); + } + + [Fact] + public void DrawPolyline_StoresPointsAndStroke() + { + var points = new List { new(0f, 10f), new(50f, 40f) }; + var polyline = new DrawPolyline(points, "#4A90D9", 1f, fill: null); + Assert.Equal(2, polyline.Points.Count); + Assert.Equal("#4A90D9", polyline.Stroke); + } + + [Fact] + public void DrawRect_StoresGeometryFillStrokeRadius() + { + var rect = new DrawRect(10f, 10f, 80f, 40f, "#eeeeee", stroke: null, strokeWidth: 0f, radius: 4f); + Assert.Equal(10f, rect.X); + Assert.Equal(80f, rect.Width); + Assert.Equal("#eeeeee", rect.Fill); + Assert.Equal(4f, rect.Radius); + } + + [Fact] + public void DrawCircle_StoresCenterRadiusFill() + { + var circle = new DrawCircle(200f, 75f, 30f, "#e74c3c", stroke: null, strokeWidth: 0f); + Assert.Equal(200f, circle.Cx); + Assert.Equal(75f, circle.Cy); + Assert.Equal(30f, circle.R); + Assert.Equal("#e74c3c", circle.Fill); + } + + [Fact] + public void DrawPath_StoresCommandsFillStroke() + { + var commands = PathDataParser.Parse("M 0 0 L 100 50 Z"); + var path = new DrawPath(commands, "#2ecc71", stroke: null, strokeWidth: 0f); + Assert.Equal(3, path.Commands.Count); + Assert.Equal("#2ecc71", path.Fill); + } +} From d01f751849612a6c87686698875d93df161d0dfe Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 13:33:23 +0400 Subject: [PATCH 013/119] feat(ast): add DrawElement --- .../Parsing/Ast/DrawElement.cs | 45 ++++++++++++++++ src/FlexRender.Core/Parsing/Ast/DrawShapes.cs | 2 +- .../Parsing/Ast/DrawElementTests.cs | 53 +++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/FlexRender.Core/Parsing/Ast/DrawElement.cs create mode 100644 tests/FlexRender.Tests/Parsing/Ast/DrawElementTests.cs diff --git a/src/FlexRender.Core/Parsing/Ast/DrawElement.cs b/src/FlexRender.Core/Parsing/Ast/DrawElement.cs new file mode 100644 index 0000000..d44caee --- /dev/null +++ b/src/FlexRender.Core/Parsing/Ast/DrawElement.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace FlexRender.Parsing.Ast; + +/// +/// A free-form drawing element. Participates in flex layout as a box with explicit +/// width/height; inside, an ordered list of absolute-coordinate shapes is painted +/// in list order (painter's algorithm). +/// +/// +/// Shapes use absolute coordinates relative to the element's top-left corner. +/// The shape list is fixed at parse time and is not expression-resolvable. +/// +public sealed class DrawElement : TemplateElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The ordered shapes to paint. + /// Thrown when is null. + public DrawElement(IReadOnlyList shapes) + { + ArgumentNullException.ThrowIfNull(shapes); + Shapes = shapes; + } + + /// + public override ElementType Type => ElementType.Draw; + + /// + /// The ordered list of shapes painted inside this element. + /// + public IReadOnlyList Shapes { get; } + + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new DrawElement(Shapes); + CopyBasePropertiesTo(clone, substitutor); + return clone; + } +} diff --git a/src/FlexRender.Core/Parsing/Ast/DrawShapes.cs b/src/FlexRender.Core/Parsing/Ast/DrawShapes.cs index e455a49..4b79303 100644 --- a/src/FlexRender.Core/Parsing/Ast/DrawShapes.cs +++ b/src/FlexRender.Core/Parsing/Ast/DrawShapes.cs @@ -3,7 +3,7 @@ namespace FlexRender.Parsing.Ast; /// -/// Base type for a single shape inside a draw element. +/// Base type for a single shape inside a . /// All coordinates are absolute, relative to the draw element's top-left corner. /// public abstract record DrawShape; diff --git a/tests/FlexRender.Tests/Parsing/Ast/DrawElementTests.cs b/tests/FlexRender.Tests/Parsing/Ast/DrawElementTests.cs new file mode 100644 index 0000000..48190c9 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Ast/DrawElementTests.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the AST class. +/// +public sealed class DrawElementTests +{ + [Fact] + public void Type_IsDraw() + { + var draw = new DrawElement(new List()); + Assert.Equal(ElementType.Draw, draw.Type); + } + + [Fact] + public void Shapes_AreExposedInOrder() + { + var shapes = new List + { + new DrawLine(0f, 0f, 10f, 10f, "#000000", 1f), + new DrawCircle(5f, 5f, 3f, "#ff0000", null, 0f) + }; + var draw = new DrawElement(shapes) { Width = "400", Height = "200" }; + + Assert.Equal(2, draw.Shapes.Count); + Assert.IsType(draw.Shapes[0]); + Assert.IsType(draw.Shapes[1]); + Assert.Equal("400", draw.Width.Value); + } + + [Fact] + public void CloneWithSubstitution_PreservesShapes() + { + var shapes = new List { new DrawLine(0f, 0f, 10f, 10f, "#000000", 1f) }; + var draw = new DrawElement(shapes) { Width = "400", Height = "200" }; + + var clone = (DrawElement)draw.CloneWithSubstitution(s => s); + + Assert.Single(clone.Shapes); + Assert.Equal("400", clone.Width.Value); + Assert.Equal("200", clone.Height.Value); + } + + [Fact] + public void Constructor_NullShapes_Throws() + { + Assert.Throws(() => new DrawElement(null!)); + } +} From 5a1740ba151d75807920a1f2f375b6e5703c7627 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 13:38:57 +0400 Subject: [PATCH 014/119] feat(layout): measure and lay out shape leaf elements --- .../Layout/IntrinsicMeasurer.cs | 21 ++++++++ src/FlexRender.Core/Layout/LayoutEngine.cs | 27 ++++++++++ .../Layout/ShapeLayoutTests.cs | 54 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 tests/FlexRender.Tests/Layout/ShapeLayoutTests.cs diff --git a/src/FlexRender.Core/Layout/IntrinsicMeasurer.cs b/src/FlexRender.Core/Layout/IntrinsicMeasurer.cs index 787feb4..b80f5ca 100644 --- a/src/FlexRender.Core/Layout/IntrinsicMeasurer.cs +++ b/src/FlexRender.Core/Layout/IntrinsicMeasurer.cs @@ -58,6 +58,10 @@ internal IntrinsicSize MeasureIntrinsic(TemplateElement element, Dictionary MeasureImageIntrinsic(image), SvgElement svg => MeasureSvgIntrinsic(svg), SeparatorElement separator => MeasureSeparatorIntrinsic(separator), + RectElement rect => MeasureShapeIntrinsic(rect), + CircleElement circle => MeasureShapeIntrinsic(circle), + EllipseElement ellipse => MeasureShapeIntrinsic(ellipse), + DrawElement draw => MeasureShapeIntrinsic(draw), FlexElement flex => MeasureFlexIntrinsic(flex, sizes), _ => new IntrinsicSize(0f, 0f, 0f, 0f) }; @@ -176,6 +180,23 @@ private static IntrinsicSize MeasureSeparatorIntrinsic(SeparatorElement separato return ApplyPaddingBorderAndMargin(intrinsic, separator); } + /// + /// Measures intrinsic size for a shape leaf element (rect, circle, ellipse, draw). + /// Shapes behave like other leaf boxes: their intrinsic content size comes from the + /// explicit / + /// (defaulting to 0 when unspecified), then padding, border, and margin are applied. + /// + /// The shape element to measure. + /// The intrinsic size of the shape including padding, border, and margin. + private static IntrinsicSize MeasureShapeIntrinsic(TemplateElement element) + { + var width = ParseAbsolutePixelValue(element.Width.Value, 0f); + var height = ParseAbsolutePixelValue(element.Height.Value, 0f); + var intrinsic = new IntrinsicSize(width, width, height, height); + + return ApplyPaddingBorderAndMargin(intrinsic, element); + } + /// /// Measures intrinsic size for a flex container element. /// diff --git a/src/FlexRender.Core/Layout/LayoutEngine.cs b/src/FlexRender.Core/Layout/LayoutEngine.cs index dc49425..0099e40 100644 --- a/src/FlexRender.Core/Layout/LayoutEngine.cs +++ b/src/FlexRender.Core/Layout/LayoutEngine.cs @@ -213,6 +213,10 @@ private LayoutNode LayoutElement(TemplateElement element, LayoutContext context) ImageElement image => LayoutImageElement(image, context), SvgElement svg => LayoutSvgElement(svg, context), SeparatorElement separator => LayoutSeparatorElement(separator, context), + RectElement rect => LayoutShapeElement(rect, context), + CircleElement circle => LayoutShapeElement(circle, context), + EllipseElement ellipse => LayoutShapeElement(ellipse, context), + DrawElement draw => LayoutShapeElement(draw, context), _ => new LayoutNode(element, 0, 0, context.ContainerWidth, DefaultTextHeight) }; node.Direction = LayoutHelpers.ResolveDirection(element, context.Direction); @@ -748,6 +752,29 @@ private static LayoutNode LayoutSeparatorElement(SeparatorElement separator, Lay return new LayoutNode(separator, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize }; } + /// + /// Lays out a shape leaf element (rect, circle, ellipse, draw). + /// Shapes behave like other leaf boxes: the explicit width defaults to the container + /// width and the explicit height defaults to 0 when unspecified, then padding and + /// border are added (border-box model). + /// + /// The shape element to lay out. + /// The current layout context. + /// The computed layout node for the shape. + private static LayoutNode LayoutShapeElement(TemplateElement shape, LayoutContext context) + { + var padding = PaddingParser.Parse(shape.Padding.Value, context.ContainerWidth, context.FontSize).ClampNegatives(); + var border = BorderParser.Resolve(shape, context.ContainerWidth, context.FontSize); + + var contentWidth = context.ResolveWidth(shape.Width.Value) ?? context.ContainerWidth; + var contentHeight = context.ResolveHeight(shape.Height.Value) ?? 0f; + + var totalWidth = contentWidth + padding.Horizontal + border.Horizontal; + var totalHeight = contentHeight + padding.Vertical + border.Vertical; + + return new LayoutNode(shape, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize }; + } + /// /// Checks whether any flow (non-absolute) child in the node has a non-default /// value. diff --git a/tests/FlexRender.Tests/Layout/ShapeLayoutTests.cs b/tests/FlexRender.Tests/Layout/ShapeLayoutTests.cs new file mode 100644 index 0000000..d95d5ae --- /dev/null +++ b/tests/FlexRender.Tests/Layout/ShapeLayoutTests.cs @@ -0,0 +1,54 @@ +using FlexRender.Configuration; +using FlexRender.Layout; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Layout; + +/// +/// Layout tests for the shape leaf elements (rect, circle, ellipse, draw). +/// +public sealed class ShapeLayoutTests +{ + private static LayoutEngine CreateEngine() => new(new ResourceLimits()); + + [Fact] + public void Rect_WithExplicitSize_ProducesThatSize() + { + var rect = new RectElement { Width = "100", Height = "50", Fill = "#ff0000" }; + var template = new Template + { + Canvas = new CanvasSettings { Width = 300, Fixed = FixedDimension.Width } + }; + template.AddElement(rect); + + var engine = CreateEngine(); + var root = engine.ComputeLayout(template); + var node = root.Children[0]; + + Assert.Equal(100f, node.Width); + Assert.Equal(50f, node.Height); + } + + [Fact] + public void Draw_WithExplicitSize_ProducesThatSize() + { + var draw = new DrawElement(new[] { (DrawShape)new DrawLine(0f, 0f, 10f, 10f, "#000", 1f) }) + { + Width = "400", + Height = "200" + }; + var template = new Template + { + Canvas = new CanvasSettings { Width = 400, Fixed = FixedDimension.Width } + }; + template.AddElement(draw); + + var engine = CreateEngine(); + var root = engine.ComputeLayout(template); + var node = root.Children[0]; + + Assert.Equal(400f, node.Width); + Assert.Equal(200f, node.Height); + } +} From 482141cfedab10f5806e6724650621534bcab752 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 13:44:23 +0400 Subject: [PATCH 015/119] feat(parser): convert gradient object form to css gradient string --- src/FlexRender.Yaml/Parsing/ShapeParsers.cs | 59 +++++++++++++ .../Parsing/GradientObjectParseTests.cs | 83 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 src/FlexRender.Yaml/Parsing/ShapeParsers.cs create mode 100644 tests/FlexRender.Tests/Parsing/GradientObjectParseTests.cs diff --git a/src/FlexRender.Yaml/Parsing/ShapeParsers.cs b/src/FlexRender.Yaml/Parsing/ShapeParsers.cs new file mode 100644 index 0000000..2777d02 --- /dev/null +++ b/src/FlexRender.Yaml/Parsing/ShapeParsers.cs @@ -0,0 +1,59 @@ +using System.Globalization; +using YamlDotNet.RepresentationModel; +using static FlexRender.Parsing.YamlPropertyHelpers; + +namespace FlexRender.Parsing; + +/// +/// Provides static helpers for parsing shape-related YAML constructs. +/// +public static class ShapeParsers +{ + /// + /// Converts a YAML gradient object (the fill object form) into FlexRender's + /// CSS gradient string so that the existing CSS gradient parser can be reused. + /// + /// The mapping node describing the gradient. + /// A CSS gradient string, e.g. linear-gradient(45deg, #ff0000, #0000ff). + /// + /// Thrown when fewer than two colors are provided, or when the gradient type is not + /// linear or radial. + /// + public static string ConvertGradientObjectToCss(YamlMappingNode node) + { + ArgumentNullException.ThrowIfNull(node); + + var type = GetStringValue(node, "gradient", "linear").Trim().ToLowerInvariant(); + + var colors = new List(); + if (TryGetSequence(node, "colors", out var colorsSeq)) + { + foreach (var item in colorsSeq.Children) + { + if (item is YamlScalarNode scalar && !string.IsNullOrWhiteSpace(scalar.Value)) + { + colors.Add(scalar.Value.Trim()); + } + } + } + + if (colors.Count < 2) + { + throw new TemplateParseException("Gradient fill requires at least two colors."); + } + + var colorList = string.Join(", ", colors); + + switch (type) + { + case "linear": + var angle = GetFloatValue(node, "angle", 0f); + return $"linear-gradient({angle.ToString(CultureInfo.InvariantCulture)}deg, {colorList})"; + case "radial": + return $"radial-gradient({colorList})"; + default: + throw new TemplateParseException( + $"Unknown gradient type '{type}'. Expected 'linear' or 'radial'."); + } + } +} diff --git a/tests/FlexRender.Tests/Parsing/GradientObjectParseTests.cs b/tests/FlexRender.Tests/Parsing/GradientObjectParseTests.cs new file mode 100644 index 0000000..668dee0 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/GradientObjectParseTests.cs @@ -0,0 +1,83 @@ +using System.IO; +using FlexRender.Parsing; +using YamlDotNet.RepresentationModel; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for converting the YAML gradient object form to a CSS gradient string. +/// +public sealed class GradientObjectParseTests +{ + private static YamlMappingNode ParseMapping(string yaml) + { + var stream = new YamlStream(); + using var reader = new StringReader(yaml); + stream.Load(reader); + return (YamlMappingNode)stream.Documents[0].RootNode; + } + + [Fact] + public void LinearGradient_WithAngleAndColors_ProducesCssString() + { + var node = ParseMapping(""" + gradient: linear + colors: ["#ff0000", "#0000ff"] + angle: 45 + """); + + var css = ShapeParsers.ConvertGradientObjectToCss(node); + + Assert.Equal("linear-gradient(45deg, #ff0000, #0000ff)", css); + } + + [Fact] + public void LinearGradient_WithoutAngle_DefaultsToZeroDeg() + { + var node = ParseMapping(""" + gradient: linear + colors: ["#fff", "#000"] + """); + + var css = ShapeParsers.ConvertGradientObjectToCss(node); + + Assert.Equal("linear-gradient(0deg, #fff, #000)", css); + } + + [Fact] + public void RadialGradient_IgnoresAngle() + { + var node = ParseMapping(""" + gradient: radial + colors: ["#fff", "#000"] + angle: 90 + """); + + var css = ShapeParsers.ConvertGradientObjectToCss(node); + + Assert.Equal("radial-gradient(#fff, #000)", css); + } + + [Fact] + public void Gradient_WithFewerThanTwoColors_Throws() + { + var node = ParseMapping(""" + gradient: linear + colors: ["#fff"] + """); + + Assert.Throws(() => ShapeParsers.ConvertGradientObjectToCss(node)); + } + + [Fact] + public void Gradient_WithUnknownType_Throws() + { + var node = ParseMapping(""" + gradient: conic + colors: ["#fff", "#000"] + """); + + Assert.Throws(() => ShapeParsers.ConvertGradientObjectToCss(node)); + } +} From 46cfa0e2a2ef43da649c026e97f5718cf25448b6 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 13:51:21 +0400 Subject: [PATCH 016/119] feat(parser): parse rect, circle, ellipse shapes with gradient fill --- .../Parsing/KnownProperties.cs | 32 ++++- src/FlexRender.Yaml/Parsing/ShapeParsers.cs | 109 ++++++++++++++++ src/FlexRender.Yaml/Parsing/TemplateParser.cs | 5 +- .../Parsing/ShapeParserTests.cs | 119 ++++++++++++++++++ 4 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 tests/FlexRender.Tests/Parsing/ShapeParserTests.cs diff --git a/src/FlexRender.Yaml/Parsing/KnownProperties.cs b/src/FlexRender.Yaml/Parsing/KnownProperties.cs index 76416bb..0cbd7e7 100644 --- a/src/FlexRender.Yaml/Parsing/KnownProperties.cs +++ b/src/FlexRender.Yaml/Parsing/KnownProperties.cs @@ -157,6 +157,33 @@ internal static class KnownProperties "rotate", "padding", "margin" ]); + /// + /// Known properties for the 'rect' element type. + /// + internal static readonly HashSet Rect = BuildSet(FlexItemProperties, + [ + "fill", "stroke", "stroke-width", "radius", + "background", "rotate", "padding", "margin" + ]); + + /// + /// Known properties for the 'circle' element type. + /// + internal static readonly HashSet Circle = BuildSet(FlexItemProperties, + [ + "fill", "stroke", "stroke-width", "size", + "background", "rotate", "padding", "margin" + ]); + + /// + /// Known properties for the 'ellipse' element type. + /// + internal static readonly HashSet Ellipse = BuildSet(FlexItemProperties, + [ + "fill", "stroke", "stroke-width", + "background", "rotate", "padding", "margin" + ]); + /// /// Maps element type names (case-insensitive) to their known property sets. /// @@ -173,7 +200,10 @@ internal static class KnownProperties ["table"] = Table, ["each"] = Each, ["if"] = If, - ["content"] = Content + ["content"] = Content, + ["rect"] = Rect, + ["circle"] = Circle, + ["ellipse"] = Ellipse }; /// diff --git a/src/FlexRender.Yaml/Parsing/ShapeParsers.cs b/src/FlexRender.Yaml/Parsing/ShapeParsers.cs index 2777d02..7e744f1 100644 --- a/src/FlexRender.Yaml/Parsing/ShapeParsers.cs +++ b/src/FlexRender.Yaml/Parsing/ShapeParsers.cs @@ -1,4 +1,5 @@ using System.Globalization; +using FlexRender.Parsing.Ast; using YamlDotNet.RepresentationModel; using static FlexRender.Parsing.YamlPropertyHelpers; @@ -56,4 +57,112 @@ public static string ConvertGradientObjectToCss(YamlMappingNode node) $"Unknown gradient type '{type}'. Expected 'linear' or 'radial'."); } } + + /// + /// Parses the fill property of a shape element. + /// A scalar value (a solid color, an existing gradient string, or a {{expression}}) + /// passes through unchanged; a mapping (the gradient object form) is converted to a CSS + /// gradient string via . + /// + /// The YAML mapping node of the shape element. + /// + /// An containing the fill value (literal color/gradient string or + /// an expression), or default when no fill property is present. + /// + internal static ExprValue ParseFill(YamlMappingNode node) + { + ArgumentNullException.ThrowIfNull(node); + + if (TryGetMapping(node, "fill", out var fillMapping)) + { + return ConvertGradientObjectToCss(fillMapping); + } + + return GetExprStringValueOptional(node, "fill"); + } + + /// + /// Parses a rect shape element from YAML. + /// + /// The YAML node containing the rect element definition. + /// The parsed . + internal static TemplateElement ParseRectElement(YamlMappingNode node) + { + ArgumentNullException.ThrowIfNull(node); + + var rect = new RectElement + { + Fill = ParseFill(node), + Stroke = GetExprStringValueOptional(node, "stroke"), + StrokeWidth = GetExprFloatValue(node, "stroke-width", 0f), + Radius = GetExprStringValueOptional(node, "radius"), + Background = GetStringValue(node, "background")!, + Rotate = GetExprStringValue(node, "rotate", "none"), + Padding = GetExprStringValue(node, "padding", "0"), + Margin = GetExprStringValue(node, "margin", "0") + }; + + ElementParsers.ApplyFlexItemProperties(node, rect); + return rect; + } + + /// + /// Parses a circle shape element from YAML. + /// The size shorthand sets both Width and Height; it is applied after the common + /// flex-item properties so that it overrides any explicit width/height. + /// + /// The YAML node containing the circle element definition. + /// The parsed . + internal static TemplateElement ParseCircleElement(YamlMappingNode node) + { + ArgumentNullException.ThrowIfNull(node); + + var circle = new CircleElement + { + Fill = ParseFill(node), + Stroke = GetExprStringValueOptional(node, "stroke"), + StrokeWidth = GetExprFloatValue(node, "stroke-width", 0f), + Background = GetStringValue(node, "background")!, + Rotate = GetExprStringValue(node, "rotate", "none"), + Padding = GetExprStringValue(node, "padding", "0"), + Margin = GetExprStringValue(node, "margin", "0") + }; + + ElementParsers.ApplyFlexItemProperties(node, circle); + + // 'size' shorthand: a circle is square, so size sets both Width and Height. + // Applied after flex-item properties so it overrides any explicit width/height. + var size = GetExprStringValueOptional(node, "size"); + if (size.Value is not null || size.IsExpression) + { + circle.Width = size; + circle.Height = size; + } + + return circle; + } + + /// + /// Parses an ellipse shape element from YAML. + /// + /// The YAML node containing the ellipse element definition. + /// The parsed . + internal static TemplateElement ParseEllipseElement(YamlMappingNode node) + { + ArgumentNullException.ThrowIfNull(node); + + var ellipse = new EllipseElement + { + Fill = ParseFill(node), + Stroke = GetExprStringValueOptional(node, "stroke"), + StrokeWidth = GetExprFloatValue(node, "stroke-width", 0f), + Background = GetStringValue(node, "background")!, + Rotate = GetExprStringValue(node, "rotate", "none"), + Padding = GetExprStringValue(node, "padding", "0"), + Margin = GetExprStringValue(node, "margin", "0") + }; + + ElementParsers.ApplyFlexItemProperties(node, ellipse); + return ellipse; + } } diff --git a/src/FlexRender.Yaml/Parsing/TemplateParser.cs b/src/FlexRender.Yaml/Parsing/TemplateParser.cs index 9694846..148bd18 100644 --- a/src/FlexRender.Yaml/Parsing/TemplateParser.cs +++ b/src/FlexRender.Yaml/Parsing/TemplateParser.cs @@ -64,7 +64,10 @@ public TemplateParser(ResourceLimits limits) ["if"] = _parsers.ParseIfElement, ["table"] = ElementParsers.ParseTableElement, ["svg"] = ElementParsers.ParseSvgElement, - ["content"] = ElementParsers.ParseContentElement + ["content"] = ElementParsers.ParseContentElement, + ["rect"] = ShapeParsers.ParseRectElement, + ["circle"] = ShapeParsers.ParseCircleElement, + ["ellipse"] = ShapeParsers.ParseEllipseElement }; } diff --git a/tests/FlexRender.Tests/Parsing/ShapeParserTests.cs b/tests/FlexRender.Tests/Parsing/ShapeParserTests.cs new file mode 100644 index 0000000..7cfd0c8 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/ShapeParserTests.cs @@ -0,0 +1,119 @@ +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for parsing rect/circle/ellipse shape elements from YAML. +/// +public sealed class ShapeParserTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_Rect_SolidFillStrokeRadius() + { + var yaml = """ + canvas: + width: 300 + layout: + - type: rect + width: 100 + height: 50 + fill: "#4A90D9" + stroke: "#333333" + stroke-width: 2 + radius: 4 + """; + + var template = _parser.Parse(yaml); + var rect = Assert.IsType(template.Elements[0]); + + Assert.Equal("#4A90D9", rect.Fill.Value); + Assert.Equal("#333333", rect.Stroke.Value); + Assert.Equal(2f, rect.StrokeWidth.Value); + Assert.Equal("4", rect.Radius.Value); + Assert.Equal("100", rect.Width.Value); + Assert.Equal("50", rect.Height.Value); + } + + [Fact] + public void Parse_Rect_GradientObjectFill_ConvertsToCss() + { + var yaml = """ + canvas: + width: 300 + layout: + - type: rect + width: 100 + height: 100 + fill: + gradient: linear + colors: ["#f00", "#00f"] + angle: 45 + """; + + var template = _parser.Parse(yaml); + var rect = Assert.IsType(template.Elements[0]); + + Assert.Equal("linear-gradient(45deg, #f00, #00f)", rect.Fill.Value); + } + + [Fact] + public void Parse_Circle_SizeShorthand_SetsWidthAndHeight() + { + var yaml = """ + canvas: + width: 300 + layout: + - type: circle + size: 40 + fill: "#e74c3c" + """; + + var template = _parser.Parse(yaml); + var circle = Assert.IsType(template.Elements[0]); + + Assert.Equal("40", circle.Width.Value); + Assert.Equal("40", circle.Height.Value); + Assert.Equal("#e74c3c", circle.Fill.Value); + } + + [Fact] + public void Parse_Ellipse_WidthHeightFill() + { + var yaml = """ + canvas: + width: 300 + layout: + - type: ellipse + width: 120 + height: 60 + fill: "#2ecc71" + """; + + var template = _parser.Parse(yaml); + var ellipse = Assert.IsType(template.Elements[0]); + + Assert.Equal("120", ellipse.Width.Value); + Assert.Equal("60", ellipse.Height.Value); + Assert.Equal("#2ecc71", ellipse.Fill.Value); + } + + [Fact] + public void Parse_Rect_UnknownProperty_SuggestsCorrection() + { + var yaml = """ + canvas: + width: 300 + layout: + - type: rect + fil: "#fff" + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("'fil'", ex.Message); + Assert.Contains("fill", ex.Message); + } +} From de7e91a155c73054a382e5d21c6640d4e9d1adab Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 13:59:08 +0400 Subject: [PATCH 017/119] feat(parser): parse draw element with absolute shapes and shape limit --- .../Parsing/KnownProperties.cs | 12 +- src/FlexRender.Yaml/Parsing/ShapeParsers.cs | 199 ++++++++++++++++++ src/FlexRender.Yaml/Parsing/TemplateParser.cs | 3 +- .../Parsing/DrawParserTests.cs | 135 ++++++++++++ 4 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 tests/FlexRender.Tests/Parsing/DrawParserTests.cs diff --git a/src/FlexRender.Yaml/Parsing/KnownProperties.cs b/src/FlexRender.Yaml/Parsing/KnownProperties.cs index 0cbd7e7..d49dbaf 100644 --- a/src/FlexRender.Yaml/Parsing/KnownProperties.cs +++ b/src/FlexRender.Yaml/Parsing/KnownProperties.cs @@ -184,6 +184,15 @@ internal static class KnownProperties "background", "rotate", "padding", "margin" ]); + /// + /// Known properties for the 'draw' element type. + /// Shapes themselves are validated structurally during parsing, not by name here. + /// + internal static readonly HashSet Draw = BuildSet(FlexItemProperties, + [ + "shapes", "background", "rotate", "padding", "margin" + ]); + /// /// Maps element type names (case-insensitive) to their known property sets. /// @@ -203,7 +212,8 @@ internal static class KnownProperties ["content"] = Content, ["rect"] = Rect, ["circle"] = Circle, - ["ellipse"] = Ellipse + ["ellipse"] = Ellipse, + ["draw"] = Draw }; /// diff --git a/src/FlexRender.Yaml/Parsing/ShapeParsers.cs b/src/FlexRender.Yaml/Parsing/ShapeParsers.cs index 7e744f1..f6e062d 100644 --- a/src/FlexRender.Yaml/Parsing/ShapeParsers.cs +++ b/src/FlexRender.Yaml/Parsing/ShapeParsers.cs @@ -165,4 +165,203 @@ internal static TemplateElement ParseEllipseElement(YamlMappingNode node) ElementParsers.ApplyFlexItemProperties(node, ellipse); return ellipse; } + + /// + /// Parses a draw element together with its ordered list of absolute-coordinate shapes. + /// + /// The YAML node containing the draw element definition. + /// The maximum number of shapes allowed per draw element. + /// The parsed . + /// + /// Thrown when the number of shapes exceeds , when a shape mapping + /// is malformed, or when path data cannot be parsed. + /// + internal static TemplateElement ParseDrawElement(YamlMappingNode node, int maxShapes) + { + ArgumentNullException.ThrowIfNull(node); + + var shapes = new List(); + + if (TryGetSequence(node, "shapes", out var shapesSeq)) + { + if (shapesSeq.Children.Count > maxShapes) + { + throw new TemplateParseException( + $"Draw element has {shapesSeq.Children.Count} shapes, which exceeds the maximum of {maxShapes} shapes per draw element."); + } + + foreach (var item in shapesSeq.Children) + { + if (item is YamlMappingNode shapeNode) + { + shapes.Add(ParseDrawShape(shapeNode)); + } + } + } + + var draw = new DrawElement(shapes) + { + Background = GetStringValue(node, "background")!, + Rotate = GetExprStringValue(node, "rotate", "none"), + Padding = GetExprStringValue(node, "padding", "0"), + Margin = GetExprStringValue(node, "margin", "0") + }; + + ElementParsers.ApplyFlexItemProperties(node, draw); + return draw; + } + + /// + /// Parses a single shape mapping, dispatching on its single shape-kind key + /// (line, polyline, rect, circle, or path). + /// + /// The YAML mapping node wrapping a single shape. + /// The parsed . + /// Thrown when the shape kind is missing or unknown. + private static DrawShape ParseDrawShape(YamlMappingNode node) + { + if (TryGetMapping(node, "line", out var lineNode)) + { + return ParseDrawLine(lineNode); + } + + if (TryGetMapping(node, "polyline", out var polylineNode)) + { + return ParseDrawPolyline(polylineNode); + } + + if (TryGetMapping(node, "rect", out var rectNode)) + { + return ParseDrawRect(rectNode); + } + + if (TryGetMapping(node, "circle", out var circleNode)) + { + return ParseDrawCircle(circleNode); + } + + if (TryGetMapping(node, "path", out var pathNode)) + { + return ParseDrawPath(pathNode); + } + + throw new TemplateParseException( + "Unknown shape in 'draw' element. Each shape must be one of: 'line', 'polyline', 'rect', 'circle', 'path'."); + } + + /// + /// Parses a line shape. + /// + /// The mapping node describing the line. + /// The parsed . + private static DrawLine ParseDrawLine(YamlMappingNode node) + => new( + GetFloatValue(node, "x1", 0f), + GetFloatValue(node, "y1", 0f), + GetFloatValue(node, "x2", 0f), + GetFloatValue(node, "y2", 0f), + GetStringValue(node, "stroke"), + GetFloatValue(node, "stroke-width", 1f)); + + /// + /// Parses a polyline shape. + /// + /// The mapping node describing the polyline. + /// The parsed . + private static DrawPolyline ParseDrawPolyline(YamlMappingNode node) + => new( + ParsePoints(node), + GetStringValue(node, "stroke"), + GetFloatValue(node, "stroke-width", 1f), + GetStringValue(node, "fill")); + + /// + /// Parses a rect shape (absolute coordinates). + /// + /// The mapping node describing the rectangle. + /// The parsed . + private static DrawRect ParseDrawRect(YamlMappingNode node) + => new( + GetFloatValue(node, "x", 0f), + GetFloatValue(node, "y", 0f), + GetFloatValue(node, "width", 0f), + GetFloatValue(node, "height", 0f), + GetStringValue(node, "fill"), + GetStringValue(node, "stroke"), + GetFloatValue(node, "stroke-width", 0f), + GetFloatValue(node, "radius", 0f)); + + /// + /// Parses a circle shape (absolute coordinates). + /// + /// The mapping node describing the circle. + /// The parsed . + private static DrawCircle ParseDrawCircle(YamlMappingNode node) + => new( + GetFloatValue(node, "cx", 0f), + GetFloatValue(node, "cy", 0f), + GetFloatValue(node, "r", 0f), + GetStringValue(node, "fill"), + GetStringValue(node, "stroke"), + GetFloatValue(node, "stroke-width", 0f)); + + /// + /// Parses a path shape, tokenizing its d attribute via . + /// + /// The mapping node describing the path. + /// The parsed . + /// Thrown when the path data is malformed. + private static DrawPath ParseDrawPath(YamlMappingNode node) + { + var d = GetStringValue(node, "d") ?? string.Empty; + + IReadOnlyList commands; + try + { + commands = PathDataParser.Parse(d); + } + catch (PathParseException ex) + { + throw new TemplateParseException($"Invalid path data in 'draw' shape: {ex.Message}", ex); + } + + return new DrawPath( + commands, + GetStringValue(node, "fill"), + GetStringValue(node, "stroke"), + GetFloatValue(node, "stroke-width", 0f)); + } + + /// + /// Parses a points sequence of the form [[x, y], [x, y], ...] into path points. + /// + /// The mapping node containing the optional points sequence. + /// The parsed points in order; empty when no valid points are present. + private static List ParsePoints(YamlMappingNode node) + { + var points = new List(); + + if (!TryGetSequence(node, "points", out var pointsSeq)) + { + return points; + } + + foreach (var item in pointsSeq.Children) + { + if (item is not YamlSequenceNode pair || pair.Children.Count < 2) + { + continue; + } + + if (pair.Children[0] is YamlScalarNode xScalar + && pair.Children[1] is YamlScalarNode yScalar + && float.TryParse(xScalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) + && float.TryParse(yScalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) + { + points.Add(new PathPoint(x, y)); + } + } + + return points; + } } diff --git a/src/FlexRender.Yaml/Parsing/TemplateParser.cs b/src/FlexRender.Yaml/Parsing/TemplateParser.cs index 148bd18..a6dad75 100644 --- a/src/FlexRender.Yaml/Parsing/TemplateParser.cs +++ b/src/FlexRender.Yaml/Parsing/TemplateParser.cs @@ -67,7 +67,8 @@ public TemplateParser(ResourceLimits limits) ["content"] = ElementParsers.ParseContentElement, ["rect"] = ShapeParsers.ParseRectElement, ["circle"] = ShapeParsers.ParseCircleElement, - ["ellipse"] = ShapeParsers.ParseEllipseElement + ["ellipse"] = ShapeParsers.ParseEllipseElement, + ["draw"] = node => ShapeParsers.ParseDrawElement(node, _limits.MaxShapesPerDraw) }; } diff --git a/tests/FlexRender.Tests/Parsing/DrawParserTests.cs b/tests/FlexRender.Tests/Parsing/DrawParserTests.cs new file mode 100644 index 0000000..d2d8086 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/DrawParserTests.cs @@ -0,0 +1,135 @@ +using FlexRender.Configuration; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for parsing the 'draw' element and its absolute-coordinate shapes. +/// +public sealed class DrawParserTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_Draw_AllShapeKinds() + { + var yaml = """ + canvas: + width: 400 + layout: + - type: draw + width: 400 + height: 200 + shapes: + - line: {x1: 0, y1: 100, x2: 400, y2: 50, stroke: "#333", stroke-width: 2} + - polyline: {points: [[0, 10], [50, 40], [100, 20]], stroke: "#4A90D9"} + - rect: {x: 10, y: 10, width: 80, height: 40, fill: "#eee", radius: 4} + - circle: {cx: 200, cy: 75, r: 30, fill: "#e74c3c"} + - path: {d: "M 0 0 L 100 50 Q 150 0 200 50 Z", fill: "#2ecc71"} + """; + + var template = _parser.Parse(yaml); + var draw = Assert.IsType(template.Elements[0]); + + Assert.Equal(5, draw.Shapes.Count); + + var line = Assert.IsType(draw.Shapes[0]); + Assert.Equal(0f, line.X1); + Assert.Equal(400f, line.X2); + Assert.Equal("#333", line.Stroke); + Assert.Equal(2f, line.StrokeWidth); + + var polyline = Assert.IsType(draw.Shapes[1]); + Assert.Equal(3, polyline.Points.Count); + Assert.Equal(50f, polyline.Points[1].X); + Assert.Equal(40f, polyline.Points[1].Y); + + var rect = Assert.IsType(draw.Shapes[2]); + Assert.Equal(80f, rect.Width); + Assert.Equal("#eee", rect.Fill); + Assert.Equal(4f, rect.Radius); + + var circle = Assert.IsType(draw.Shapes[3]); + Assert.Equal(200f, circle.Cx); + Assert.Equal(30f, circle.R); + + var path = Assert.IsType(draw.Shapes[4]); + Assert.Equal("#2ecc71", path.Fill); + Assert.True(path.Commands.Count >= 4); + } + + [Fact] + public void Parse_Draw_NoShapes_ProducesEmptyList() + { + var yaml = """ + canvas: + width: 400 + layout: + - type: draw + width: 400 + height: 200 + """; + + var template = _parser.Parse(yaml); + var draw = Assert.IsType(template.Elements[0]); + Assert.Empty(draw.Shapes); + } + + [Fact] + public void Parse_Draw_MalformedPath_ThrowsWithCommand() + { + var yaml = """ + canvas: + width: 400 + layout: + - type: draw + width: 400 + height: 200 + shapes: + - path: {d: "M 0 0 X 1 1"} + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("'X'", ex.Message); + } + + [Fact] + public void Parse_Draw_ExceedsShapeLimit_Throws() + { + var limits = new ResourceLimits { MaxShapesPerDraw = 2 }; + var parser = new TemplateParser(limits); + + var yaml = """ + canvas: + width: 400 + layout: + - type: draw + width: 400 + height: 200 + shapes: + - line: {x1: 0, y1: 0, x2: 1, y2: 1} + - line: {x1: 0, y1: 0, x2: 1, y2: 1} + - line: {x1: 0, y1: 0, x2: 1, y2: 1} + """; + + var ex = Assert.Throws(() => parser.Parse(yaml)); + Assert.Contains("shapes", ex.Message); + } + + [Fact] + public void Parse_Draw_UnknownProperty_Throws() + { + var yaml = """ + canvas: + width: 400 + layout: + - type: draw + width: 400 + shaps: [] + """; + + Assert.Throws(() => _parser.Parse(yaml)); + } +} From ab4f8b432283fd97fb44436049473d2fd3855739 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 14:04:46 +0400 Subject: [PATCH 018/119] fix(parser): reject malformed draw shapes and polyline points --- src/FlexRender.Yaml/Parsing/ShapeParsers.cs | 24 +++++++------- .../Parsing/DrawParserTests.cs | 32 +++++++++++++++++++ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/FlexRender.Yaml/Parsing/ShapeParsers.cs b/src/FlexRender.Yaml/Parsing/ShapeParsers.cs index f6e062d..7dcdb54 100644 --- a/src/FlexRender.Yaml/Parsing/ShapeParsers.cs +++ b/src/FlexRender.Yaml/Parsing/ShapeParsers.cs @@ -192,10 +192,13 @@ internal static TemplateElement ParseDrawElement(YamlMappingNode node, int maxSh foreach (var item in shapesSeq.Children) { - if (item is YamlMappingNode shapeNode) + if (item is not YamlMappingNode shapeNode) { - shapes.Add(ParseDrawShape(shapeNode)); + throw new TemplateParseException( + "Each entry in 'shapes' must be a mapping with one shape kind (line, polyline, rect, circle, path)."); } + + shapes.Add(ParseDrawShape(shapeNode)); } } @@ -348,18 +351,17 @@ private static List ParsePoints(YamlMappingNode node) foreach (var item in pointsSeq.Children) { - if (item is not YamlSequenceNode pair || pair.Children.Count < 2) + if (item is not YamlSequenceNode pair || pair.Children.Count < 2 + || pair.Children[0] is not YamlScalarNode xScalar + || pair.Children[1] is not YamlScalarNode yScalar + || !float.TryParse(xScalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) + || !float.TryParse(yScalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) { - continue; + throw new TemplateParseException( + "Each polyline point must be a [x, y] pair of numbers."); } - if (pair.Children[0] is YamlScalarNode xScalar - && pair.Children[1] is YamlScalarNode yScalar - && float.TryParse(xScalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) - && float.TryParse(yScalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var y)) - { - points.Add(new PathPoint(x, y)); - } + points.Add(new PathPoint(x, y)); } return points; diff --git a/tests/FlexRender.Tests/Parsing/DrawParserTests.cs b/tests/FlexRender.Tests/Parsing/DrawParserTests.cs index d2d8086..9641f17 100644 --- a/tests/FlexRender.Tests/Parsing/DrawParserTests.cs +++ b/tests/FlexRender.Tests/Parsing/DrawParserTests.cs @@ -132,4 +132,36 @@ public void Parse_Draw_UnknownProperty_Throws() Assert.Throws(() => _parser.Parse(yaml)); } + + [Fact] + public void Parse_Draw_NonMappingShapeEntry_Throws() + { + var yaml = """ + canvas: + width: 400 + layout: + - type: draw + width: 400 + height: 200 + shapes: + - "not a shape" + """; + Assert.Throws(() => _parser.Parse(yaml)); + } + + [Fact] + public void Parse_Draw_MalformedPolylinePoints_Throws() + { + var yaml = """ + canvas: + width: 400 + layout: + - type: draw + width: 400 + height: 200 + shapes: + - polyline: {points: [10, 20]} + """; + Assert.Throws(() => _parser.Parse(yaml)); + } } From d7ca4dca0cf87757cd085c7813926657f41a5385 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 14:09:18 +0400 Subject: [PATCH 019/119] feat(renderer): draw rect, circle, ellipse shapes via skia --- .../Rendering/RenderingEngine.cs | 12 + .../Rendering/ShapeRenderer.cs | 224 ++++++++++++++++++ .../Rendering/ShapeRenderSmokeTests.cs | 63 +++++ 3 files changed, 299 insertions(+) create mode 100644 src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs create mode 100644 tests/FlexRender.Tests/Rendering/ShapeRenderSmokeTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs index e8a6cfe..c802b2c 100644 --- a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs +++ b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs @@ -332,6 +332,18 @@ private void DrawElement( } break; + case RectElement rect: + ShapeRenderer.DrawRect(canvas, rect, x, y, width, height, effectiveFontSize, renderOptions.Antialiasing); + break; + + case CircleElement circle: + ShapeRenderer.DrawCircle(canvas, circle, x, y, width, height, renderOptions.Antialiasing); + break; + + case EllipseElement ellipse: + ShapeRenderer.DrawEllipse(canvas, ellipse, x, y, width, height, renderOptions.Antialiasing); + break; + case SeparatorElement separator: DrawSeparator(canvas, separator, x, y, width, height, renderOptions.Antialiasing); break; diff --git a/src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs new file mode 100644 index 0000000..1d427c8 --- /dev/null +++ b/src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs @@ -0,0 +1,224 @@ +using System; +using FlexRender.Layout.Units; +using FlexRender.Parsing.Ast; +using SkiaSharp; + +namespace FlexRender.Rendering; + +/// +/// Draws box shape elements (rectangle, circle, ellipse) to a SkiaSharp canvas. +/// Each shape supports a solid color or CSS gradient fill plus an optional solid stroke. +/// +internal static class ShapeRenderer +{ + /// + /// Draws a as a filled and/or stroked rectangle, using rounded + /// corners when a positive corner radius is specified. + /// + /// The canvas to draw on. + /// The rectangle element describing fill, stroke, and corner radius. + /// The left edge of the shape box in pixels. + /// The top edge of the shape box in pixels. + /// The width of the shape box in pixels. + /// The height of the shape box in pixels. + /// The effective font size in pixels, used to resolve em-based radii. + /// Whether to enable anti-aliasing for the fill and stroke. + /// Thrown when or is null. + public static void DrawRect( + SKCanvas canvas, + RectElement rect, + float x, + float y, + float width, + float height, + float fontSize, + bool antialias) + { + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(rect); + + var radius = ResolveRadius(rect.Radius.Value, Math.Min(width, height), fontSize); + + FillRegion(canvas, rect.Fill.Value, x, y, width, height, antialias, paint => + { + if (radius > 0f) + canvas.DrawRoundRect(x, y, width, height, radius, radius, paint); + else + canvas.DrawRect(x, y, width, height, paint); + }); + + if (TryCreateStrokePaint(rect.Stroke.Value, rect.StrokeWidth.Value, antialias, out var strokePaint)) + { + using (strokePaint) + { + if (radius > 0f) + canvas.DrawRoundRect(x, y, width, height, radius, radius, strokePaint); + else + canvas.DrawRect(x, y, width, height, strokePaint); + } + } + } + + /// + /// Draws a as a filled and/or stroked circle inscribed in the box. + /// The diameter is the smaller of and . + /// + /// The canvas to draw on. + /// The circle element describing fill and stroke. + /// The left edge of the shape box in pixels. + /// The top edge of the shape box in pixels. + /// The width of the shape box in pixels. + /// The height of the shape box in pixels. + /// Whether to enable anti-aliasing for the fill and stroke. + /// Thrown when or is null. + public static void DrawCircle( + SKCanvas canvas, + CircleElement circle, + float x, + float y, + float width, + float height, + bool antialias) + { + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(circle); + + var diameter = Math.Min(width, height); + var r = diameter / 2f; + var cx = x + (width / 2f); + var cy = y + (height / 2f); + + FillRegion(canvas, circle.Fill.Value, x, y, width, height, antialias, + paint => canvas.DrawCircle(cx, cy, r, paint)); + + if (TryCreateStrokePaint(circle.Stroke.Value, circle.StrokeWidth.Value, antialias, out var strokePaint)) + { + using (strokePaint) + canvas.DrawCircle(cx, cy, r, strokePaint); + } + } + + /// + /// Draws an as a filled and/or stroked ellipse inscribed in the box. + /// + /// The canvas to draw on. + /// The ellipse element describing fill and stroke. + /// The left edge of the shape box in pixels. + /// The top edge of the shape box in pixels. + /// The width of the shape box in pixels. + /// The height of the shape box in pixels. + /// Whether to enable anti-aliasing for the fill and stroke. + /// Thrown when or is null. + public static void DrawEllipse( + SKCanvas canvas, + EllipseElement ellipse, + float x, + float y, + float width, + float height, + bool antialias) + { + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(ellipse); + + var cx = x + (width / 2f); + var cy = y + (height / 2f); + var rx = width / 2f; + var ry = height / 2f; + + FillRegion(canvas, ellipse.Fill.Value, x, y, width, height, antialias, + paint => canvas.DrawOval(cx, cy, rx, ry, paint)); + + if (TryCreateStrokePaint(ellipse.Stroke.Value, ellipse.StrokeWidth.Value, antialias, out var strokePaint)) + { + using (strokePaint) + canvas.DrawOval(cx, cy, rx, ry, strokePaint); + } + } + + /// + /// Fills a shape region using either a CSS gradient or a solid color, invoking + /// with the configured fill paint. Does nothing when + /// is null or empty. + /// + /// The canvas the shape is drawn on (used to size gradients). + /// The fill value: a solid color or a CSS gradient function string. + /// The left edge of the shape box in pixels. + /// The top edge of the shape box in pixels. + /// The width of the shape box in pixels. + /// The height of the shape box in pixels. + /// Whether to enable anti-aliasing for the fill. + /// The action that draws the geometry with the supplied paint. + private static void FillRegion( + SKCanvas canvas, + string? fill, + float x, + float y, + float width, + float height, + bool antialias, + Action draw) + { + if (string.IsNullOrEmpty(fill)) + return; + + if (GradientParser.IsGradient(fill) && + GradientParser.TryParse(fill, out var gradient) && + gradient is not null) + { + using var shader = GradientParser.CreateShader(gradient, x, y, width, height); + if (shader is not null) + { + using var gradientPaint = new SKPaint { Shader = shader, Style = SKPaintStyle.Fill, IsAntialias = antialias }; + draw(gradientPaint); + return; + } + } + + using var paint = new SKPaint { Color = ColorParser.Parse(fill), Style = SKPaintStyle.Fill, IsAntialias = antialias }; + draw(paint); + } + + /// + /// Attempts to create a solid stroke paint when a stroke color is present and the stroke + /// width is positive. + /// + /// The stroke color string, or null/empty for no stroke. + /// The stroke width in pixels; must be greater than zero to stroke. + /// Whether to enable anti-aliasing for the stroke. + /// The created stroke paint when this method returns true; otherwise null. + /// when a stroke paint was created; otherwise . + private static bool TryCreateStrokePaint(string? stroke, float strokeWidth, bool antialias, out SKPaint paint) + { + if (string.IsNullOrEmpty(stroke) || strokeWidth <= 0f) + { + paint = null!; + return false; + } + + paint = new SKPaint + { + Color = ColorParser.Parse(stroke), + Style = SKPaintStyle.Stroke, + StrokeWidth = strokeWidth, + IsAntialias = antialias + }; + return true; + } + + /// + /// Resolves a corner radius string (e.g. "8", "0.5em") to pixels, returning zero when the + /// value is empty or cannot be resolved. + /// + /// The radius value string, or null/empty for square corners. + /// The reference size for percentage radii in pixels. + /// The effective font size in pixels for em-based radii. + /// The resolved radius in pixels, or zero. + private static float ResolveRadius(string? radius, float containerSize, float fontSize) + { + if (string.IsNullOrEmpty(radius)) + return 0f; + + return UnitParser.Parse(radius).Resolve(containerSize, fontSize) ?? 0f; + } +} diff --git a/tests/FlexRender.Tests/Rendering/ShapeRenderSmokeTests.cs b/tests/FlexRender.Tests/Rendering/ShapeRenderSmokeTests.cs new file mode 100644 index 0000000..c46f692 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ShapeRenderSmokeTests.cs @@ -0,0 +1,63 @@ +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using FlexRender.TemplateEngine; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Smoke tests verifying that box shapes (rect/circle/ellipse) are actually drawn to the canvas +/// by the Skia rendering pipeline, not merely laid out. +/// +public sealed class ShapeRenderSmokeTests : IDisposable +{ + private readonly SkiaRenderer _renderer = new(); + private readonly TemplateParser _parser = new(); + + /// Releases the renderer used by the tests. + public void Dispose() + { + _renderer.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task Rect_WithRedFill_DrawsRedAtCanvasCenter() + { + const string yaml = """ + canvas: + width: 120 + height: 80 + background: "#ffffff" + layout: + - type: rect + width: 100 + height: 50 + fill: "#ff0000" + margin: "10" + """; + + var template = _parser.Parse(yaml); + var data = new ObjectValue(); + + using var bitmap = await Render(template, data); + + var center = bitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2); + Assert.True(center.Red > 200, $"Expected red center, got {center}"); + Assert.True(center.Green < 80, $"Expected red center, got {center}"); + Assert.True(center.Blue < 80, $"Expected red center, got {center}"); + } + + private async Task Render(Template template, ObjectValue data) + { + var size = await _renderer.MeasureAsync(template, data); + var width = Math.Max((int)Math.Ceiling(size.Width), 1); + var height = Math.Max((int)Math.Ceiling(size.Height), 1); + + var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); + await _renderer.Render(bitmap, template, data, default, default); + return bitmap; + } +} From f8c6b5829cec43b6bd5d23595b2f4500ae9b8604 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 14:13:21 +0400 Subject: [PATCH 020/119] refactor(renderer): tighten ShapeRenderer draw method visibility to internal --- src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs index 1d427c8..253e871 100644 --- a/src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs @@ -24,7 +24,7 @@ internal static class ShapeRenderer /// The effective font size in pixels, used to resolve em-based radii. /// Whether to enable anti-aliasing for the fill and stroke. /// Thrown when or is null. - public static void DrawRect( + internal static void DrawRect( SKCanvas canvas, RectElement rect, float x, @@ -71,7 +71,7 @@ public static void DrawRect( /// The height of the shape box in pixels. /// Whether to enable anti-aliasing for the fill and stroke. /// Thrown when or is null. - public static void DrawCircle( + internal static void DrawCircle( SKCanvas canvas, CircleElement circle, float x, @@ -109,7 +109,7 @@ public static void DrawCircle( /// The height of the shape box in pixels. /// Whether to enable anti-aliasing for the fill and stroke. /// Thrown when or is null. - public static void DrawEllipse( + internal static void DrawEllipse( SKCanvas canvas, EllipseElement ellipse, float x, From 4b13fac83acedafceebba73310170f5124257e78 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 14:16:47 +0400 Subject: [PATCH 021/119] feat(renderer): draw line, polyline, rect, circle, path inside draw element --- .../Rendering/RenderingEngine.cs | 4 + .../Rendering/ShapeRenderer.cs | 191 ++++++++++++++++++ .../Rendering/DrawRenderSmokeTests.cs | 63 ++++++ 3 files changed, 258 insertions(+) create mode 100644 tests/FlexRender.Tests/Rendering/DrawRenderSmokeTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs index c802b2c..1fd702f 100644 --- a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs +++ b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs @@ -344,6 +344,10 @@ private void DrawElement( ShapeRenderer.DrawEllipse(canvas, ellipse, x, y, width, height, renderOptions.Antialiasing); break; + case DrawElement drawEl: + ShapeRenderer.DrawShapes(canvas, drawEl, x, y, width, height, renderOptions.Antialiasing); + break; + case SeparatorElement separator: DrawSeparator(canvas, separator, x, y, width, height, renderOptions.Antialiasing); break; diff --git a/src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs index 253e871..f919e7c 100644 --- a/src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ShapeRenderer.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using FlexRender.Layout.Units; +using FlexRender.Parsing; using FlexRender.Parsing.Ast; using SkiaSharp; @@ -221,4 +223,193 @@ private static float ResolveRadius(string? radius, float containerSize, float fo return UnitParser.Parse(radius).Resolve(containerSize, fontSize) ?? 0f; } + + /// + /// Paints the ordered shapes of a inside its layout box. + /// Shapes use absolute coordinates relative to the box's top-left corner; the canvas is + /// clipped to the box and translated so each shape's coordinates are box-local. + /// Shape fills are solid colors only (no gradients). + /// + /// The canvas to draw on. + /// The draw element whose shapes are painted in list order. + /// The left edge of the draw box in pixels. + /// The top edge of the draw box in pixels. + /// The width of the draw box in pixels. + /// The height of the draw box in pixels. + /// Whether to enable anti-aliasing for fills and strokes. + /// Thrown when or is null. + internal static void DrawShapes( + SKCanvas canvas, + DrawElement draw, + float x, + float y, + float width, + float height, + bool antialias) + { + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(draw); + + canvas.Save(); + try + { + canvas.ClipRect(new SKRect(x, y, x + width, y + height)); + canvas.Translate(x, y); + + foreach (var shape in draw.Shapes) + { + switch (shape) + { + case DrawLine line: + DrawLineShape(canvas, line, antialias); + break; + case DrawPolyline polyline: + DrawPolylineShape(canvas, polyline, antialias); + break; + case DrawRect rect: + DrawRectShape(canvas, rect, antialias); + break; + case DrawCircle circle: + DrawCircleShape(canvas, circle, antialias); + break; + case DrawPath path: + DrawPathShape(canvas, path, antialias); + break; + } + } + } + finally + { + canvas.Restore(); + } + } + + /// Draws a as a stroked segment, defaulting to black. + private static void DrawLineShape(SKCanvas canvas, DrawLine line, bool antialias) + { + if (TryCreateStrokePaint(line.Stroke ?? "#000000", line.StrokeWidth, antialias, out var paint)) + { + using (paint) + canvas.DrawLine(line.X1, line.Y1, line.X2, line.Y2, paint); + } + } + + /// Draws a , optionally filling the enclosed area, then stroking (default black). + private static void DrawPolylineShape(SKCanvas canvas, DrawPolyline polyline, bool antialias) + { + if (polyline.Points.Count < 2) + return; + + using var path = new SKPath(); + var first = polyline.Points[0]; + path.MoveTo(first.X, first.Y); + for (var i = 1; i < polyline.Points.Count; i++) + path.LineTo(polyline.Points[i].X, polyline.Points[i].Y); + + FillSolid(polyline.Fill, antialias, paint => canvas.DrawPath(path, paint)); + + if (TryCreateStrokePaint(polyline.Stroke ?? "#000000", polyline.StrokeWidth, antialias, out var strokePaint)) + { + using (strokePaint) + canvas.DrawPath(path, strokePaint); + } + } + + /// Draws a with an optional solid fill and optional stroke, honouring corner radius. + private static void DrawRectShape(SKCanvas canvas, DrawRect rect, bool antialias) + { + var radius = rect.Radius; + + FillSolid(rect.Fill, antialias, + paint => DrawRectGeometry(canvas, rect.X, rect.Y, rect.Width, rect.Height, radius, paint)); + + if (TryCreateStrokePaint(rect.Stroke, rect.StrokeWidth, antialias, out var strokePaint)) + { + using (strokePaint) + DrawRectGeometry(canvas, rect.X, rect.Y, rect.Width, rect.Height, radius, strokePaint); + } + } + + /// Draws a with an optional solid fill and optional stroke. + private static void DrawCircleShape(SKCanvas canvas, DrawCircle circle, bool antialias) + { + FillSolid(circle.Fill, antialias, + paint => canvas.DrawCircle(circle.Cx, circle.Cy, circle.R, paint)); + + if (TryCreateStrokePaint(circle.Stroke, circle.StrokeWidth, antialias, out var strokePaint)) + { + using (strokePaint) + canvas.DrawCircle(circle.Cx, circle.Cy, circle.R, strokePaint); + } + } + + /// Draws a built from pre-parsed absolute commands, with optional fill and stroke. + private static void DrawPathShape(SKCanvas canvas, DrawPath drawPath, bool antialias) + { + using var path = BuildPath(drawPath.Commands); + + FillSolid(drawPath.Fill, antialias, paint => canvas.DrawPath(path, paint)); + + if (TryCreateStrokePaint(drawPath.Stroke, drawPath.StrokeWidth, antialias, out var strokePaint)) + { + using (strokePaint) + canvas.DrawPath(path, strokePaint); + } + } + + /// Builds an from parsed path commands (MoveTo/LineTo/QuadTo/CubicTo/Close). + private static SKPath BuildPath(IReadOnlyList commands) + { + var path = new SKPath(); + foreach (var command in commands) + { + switch (command.Kind) + { + case PathCommandKind.MoveTo: + path.MoveTo(command.Points[0].X, command.Points[0].Y); + break; + case PathCommandKind.LineTo: + path.LineTo(command.Points[0].X, command.Points[0].Y); + break; + case PathCommandKind.QuadTo: + path.QuadTo( + command.Points[0].X, command.Points[0].Y, + command.Points[1].X, command.Points[1].Y); + break; + case PathCommandKind.CubicTo: + path.CubicTo( + command.Points[0].X, command.Points[0].Y, + command.Points[1].X, command.Points[1].Y, + command.Points[2].X, command.Points[2].Y); + break; + case PathCommandKind.Close: + path.Close(); + break; + } + } + + return path; + } + + /// Draws a sharp or rounded rectangle depending on whether is positive. + private static void DrawRectGeometry(SKCanvas canvas, float x, float y, float width, float height, float radius, SKPaint paint) + { + if (radius > 0f) + canvas.DrawRoundRect(x, y, width, height, radius, radius, paint); + else + canvas.DrawRect(x, y, width, height, paint); + } + + /// + /// Invokes with a solid fill paint when is a + /// non-empty color; does nothing otherwise. Used for draw-element shapes, which never use gradients. + /// + private static void FillSolid(string? fill, bool antialias, Action draw) + { + if (string.IsNullOrEmpty(fill)) + return; + + using var paint = new SKPaint { Color = ColorParser.Parse(fill), Style = SKPaintStyle.Fill, IsAntialias = antialias }; + draw(paint); + } } diff --git a/tests/FlexRender.Tests/Rendering/DrawRenderSmokeTests.cs b/tests/FlexRender.Tests/Rendering/DrawRenderSmokeTests.cs new file mode 100644 index 0000000..d0535ad --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/DrawRenderSmokeTests.cs @@ -0,0 +1,63 @@ +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using FlexRender.TemplateEngine; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Smoke tests verifying that absolute-coordinate shapes inside a draw element +/// (line/polyline/rect/circle/path) are actually painted to the canvas by the Skia pipeline. +/// +public sealed class DrawRenderSmokeTests : IDisposable +{ + private readonly SkiaRenderer _renderer = new(); + private readonly TemplateParser _parser = new(); + + /// Releases the renderer used by the tests. + public void Dispose() + { + _renderer.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task Draw_WithBlueFilledCircle_DrawsBlueAtCircleCentre() + { + const string yaml = """ + canvas: + width: 200 + height: 150 + background: "#ffffff" + layout: + - type: draw + width: 200 + height: 150 + shapes: + - circle: {cx: 100, cy: 75, r: 40, fill: "#0000ff"} + """; + + var template = _parser.Parse(yaml); + var data = new ObjectValue(); + + using var bitmap = await Render(template, data); + + var center = bitmap.GetPixel(100, 75); + Assert.True(center.Blue > 200, $"Expected blue centre, got {center}"); + Assert.True(center.Red < 80, $"Expected blue centre, got {center}"); + Assert.True(center.Green < 80, $"Expected blue centre, got {center}"); + } + + private async Task Render(Template template, ObjectValue data) + { + var size = await _renderer.MeasureAsync(template, data); + var width = Math.Max((int)Math.Ceiling(size.Width), 1); + var height = Math.Max((int)Math.Ceiling(size.Height), 1); + + var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); + await _renderer.Render(bitmap, template, data, default, default); + return bitmap; + } +} From e776237b86f47e4d094a4d979fd2c6e462fdf9ea Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 14:24:02 +0400 Subject: [PATCH 022/119] test(renderer): add shape and draw snapshot golden images --- .../Snapshots/ShapeSnapshotTests.cs | 123 ++++++++++++++++++ .../Snapshots/golden/draw_overlap.png | Bin 0 -> 4137 bytes .../Snapshots/golden/shapes_box_basic.png | Bin 0 -> 3875 bytes .../Snapshots/golden/shapes_gradient.png | Bin 0 -> 5154 bytes 4 files changed, 123 insertions(+) create mode 100644 tests/FlexRender.Tests/Snapshots/ShapeSnapshotTests.cs create mode 100644 tests/FlexRender.Tests/Snapshots/golden/draw_overlap.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/shapes_box_basic.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/shapes_gradient.png diff --git a/tests/FlexRender.Tests/Snapshots/ShapeSnapshotTests.cs b/tests/FlexRender.Tests/Snapshots/ShapeSnapshotTests.cs new file mode 100644 index 0000000..91e58c1 --- /dev/null +++ b/tests/FlexRender.Tests/Snapshots/ShapeSnapshotTests.cs @@ -0,0 +1,123 @@ +using Xunit; + +namespace FlexRender.Tests.Snapshots; + +/// +/// Visual snapshot tests for shape elements (rect, circle, ellipse) and the draw element. +/// Covers basic box shapes, gradient fills (linear and radial), and painter's-order +/// compositing of draw primitives (rect, line, polyline, circle, path). +/// +/// +/// Run with UPDATE_SNAPSHOTS=true to regenerate golden images. +/// +public sealed class ShapeSnapshotTests : SnapshotTestBase +{ + /// + /// Tests basic box shapes: a rounded stroked rectangle, a filled circle, and a stroked ellipse. + /// + [Fact] + public async Task Shapes_BoxBasic_RectCircleEllipse() + { + const string yaml = """ + canvas: + width: 260 + height: 90 + fixed: both + background: "#ffffff" + layout: + - type: flex + direction: row + gap: "10" + padding: "10" + align: center + children: + - type: rect + width: 70 + height: 50 + fill: "#4A90D9" + stroke: "#1f3a5f" + stroke-width: 2 + radius: 6 + - type: circle + size: 50 + fill: "#e74c3c" + - type: ellipse + width: 80 + height: 50 + fill: "#2ecc71" + stroke: "#145a32" + stroke-width: 2 + """; + + var template = Parser.Parse(yaml); + + await AssertSnapshot("shapes_box_basic", template, new ObjectValue()); + } + + /// + /// Tests gradient fills on box shapes: a linear gradient rectangle and a radial gradient circle. + /// + [Fact] + public async Task Shapes_Gradient_LinearAndRadial() + { + const string yaml = """ + canvas: + width: 220 + height: 110 + fixed: both + background: "#ffffff" + layout: + - type: flex + direction: row + gap: "10" + padding: "10" + children: + - type: rect + width: 90 + height: 90 + fill: + gradient: linear + colors: ["#ff0000", "#0000ff"] + angle: 45 + - type: circle + size: 90 + fill: + gradient: radial + colors: ["#ffffff", "#222222"] + """; + + var template = Parser.Parse(yaml); + + await AssertSnapshot("shapes_gradient", template, new ObjectValue()); + } + + /// + /// Tests painter's-order compositing of draw primitives: a rounded rect, a line, + /// a polyline, a circle, and a closed path, each drawn over the previous. + /// + [Fact] + public async Task Draw_Overlap_PaintersOrder() + { + const string yaml = """ + canvas: + width: 200 + height: 160 + fixed: both + background: "#ffffff" + layout: + - type: draw + width: 200 + height: 160 + shapes: + - rect: {x: 20, y: 20, width: 120, height: 80, fill: "#cccccc", radius: 8} + - line: {x1: 0, y1: 80, x2: 200, y2: 40, stroke: "#333333", stroke-width: 3} + - polyline: {points: [[10, 140], [60, 110], [110, 130], [160, 100]], stroke: "#4A90D9", stroke-width: 2} + - circle: {cx: 130, cy: 70, r: 35, fill: "#e74c3c"} + - path: {d: "M 20 150 L 80 110 Q 120 90 160 120 Z", fill: "#2ecc71"} + """; + + var template = Parser.Parse(yaml); + + await AssertSnapshot("draw_overlap", template, new ObjectValue()); + } +} diff --git a/tests/FlexRender.Tests/Snapshots/golden/draw_overlap.png b/tests/FlexRender.Tests/Snapshots/golden/draw_overlap.png new file mode 100644 index 0000000000000000000000000000000000000000..0251ae8b40d9a35bf1ce820931f770700577ffd2 GIT binary patch literal 4137 zcmbt%cRUn+{Qo&;uQNiIac30L8J(@1mCZTptjvrkWQOdSb>)*y2t|ib(TQxzyyA>9 zE~7X?oISqR@6X@k@%`)je!R!y@qCT<^Zk17RFtV68#9C%006KV= zTEJHCPQ-h;6<6&vwtG*@V#meaGBMq|398Cpt#GNb@XA&^#Cl)JW@=eQ!ms^j3`P1N zs}wVbb=~6KH>92*L+f#abro%Q{Z5^YoPC1dJ{?inj0kx?pMEp!ZN!ME_gPfQjof|4 zXc)|NMOoL&8WPPo1SyFCH{Y$PfBo0#bUo{3n6KRfK);H`KUEld%k1@xfMq2d3Wy z(+`9Jkock0pby;M$=s`r58R946<-`NQ(qI(C;E=`oa2D#j*2l>_h=5>quDp?Y{&t3 z9w!-7lMeNFCwmDv2@%3nT*6Di)udtU-8BTRK&Hm&&)mdB<;uzcB%gNi4JAnP4>|TB zx-cTZ_|IjG-w(EjoA+cD6nq*vZljRQ3Etsccl=Hu4>phSe(@@_=Du4{5Dx%lZmvlqCnYDOyP7)b68#ILbGz$AnRzlz$E5N|jD@mII55iaz`_ zzqnmmTbluNMLZa_ZHYRR2F=wMDCVQiGW;~>0$;DRKGqT0IzHVxj5KoU?(Tk=?6 z#PadB3^mWp%#8i`!P+#8t z6}L$$fl;dDmAW@a^mvO9|<=Kg3o<~Mo zO$x@5Wpe(Md&u7YK~%J;3s#J2czD>zWh+eG>)t)bK>i11>|ISkkdfTyN|DL{=yjFY z55iHx7tGE81RbpjS>O3~LEbdoY;TlMP9{n}LLG5!NHn|g;=&xi4>!;zIk>Bh6XQ>r2U7m^$# zpha>fPCC7T=k06Hns0eI<~c1{ z-ddN2loI?#IHxm;c;~lA*)IWJ{AeRwo0Bd05M_>esBGq1phLrGP$BHN}d;L6HxDoE6%L8T~n01tO`q+VI^+3nd>KCbW;?s*H zpaR(Aa7=E8!+OspoY6oku**)Bj_em)8eej zk2jxm?MR)2`f`~@-oQ!Bx|gu-@~KaraL#sN)A&h&j#qQOmU$D!9m*ttPmM3jsj5CE zkw}%Y=~-E#+5I>8?k2+c)}Vvt3T&}%3ef53-eT4sfvk82f6q0qh)1~ArV+Za zEWz02SMf__;BI7k>O4mbf~aX?=b`X;l?{Mn;BBPU&*a>Vx^sK=~0xSTay1? z*M>8~^jVsl$L8l7z1){%JxWuGF#obzfC_KgnCZqR+%e9&c_dL}dS&MOcS%-OvAXG` z^a<--&vCjBpemvw@s6~cs{aCj40&AWuH{3AwMXxY3Uh(kL=)r?L1ftLG5XEsRz^+s( zMcx8COm3Ol;K(KC)v3?;^b6)lW%<@~I8prf$o7~h&Im=_Edz1K1?5K__rEs@uk`s) z^{K}BA5KGIgCvsaM5>5@-ho}R5+5_8C;A;FuWeD5ERXng@KqFh_fX@39t{b#RP~54 zFwF6(ogw=p`n~SwZjQHZTXLj)?uiskiIa_35bF6j*#OrLCOSqeg#E6!&3&RYL5oJC z%l9s)WkX$8%!H6%0dD&3L$&w(Z|*6B2+Hv+bP1jwELDHbnZMpy#$BD3_5SA3(taw) zNN=BbixOjLZT;XOA>aJYm!g(~t$C=+LMz9$$x|Vz^JP8oU=hy9_B!l!=L=ohSU_K2 z-}szwfYHl^NDAH5{4pEZkf_ssnq|H>;06{;4VEOA8z38}r6X?&yEf#_6vD&BH;dzT zmVd$g4+fU5<{3*IuPLD(^v_aRfK7mLgRqOj!g^5fnCp)2@R5tK!{{kP1?E5M9m13; zPo?OHQMQk9xq)CADAyN@tFD&pSq$iLm82#-MlfIqUi7!~8fz242nL^N~$x0%_cP=dCzi@m%7}H&`4#TQ85DFC2gl5)BT&E2*+ z_F1SyjC|H&P8n$&C|8qDA3-Up#?SH_zOhG`4!INf4ZE+)CliqZC3)$*pXt(G)1$2@ zmGuRxdG_^*sK)o=W$DVM%ha=5Q>w7<`_8BCZ4}2GE5%v<^RFGkz4D6@U%vY%lf%~~ z{o7H*>$RQj#ICTnv~)V+wA+BFNRq44}hbBNc?jJlxp-#^OkSJ`azosDh^h%i;f|+Ew|+vjr0+*?l9MbhaZKGkiB;^#95q^mwFdVS_!^`n`89^o*?AhdSSoC6}Sk$ zRl9ZdyOAW?jpeCHJNnn&l)ArmTaVP`PSoS7Qyx!1asf09S3`dKP#8$>tsCHaR&*D! zq*c(_yB(2{tbS|tasE7$pU?h)Z*LZN`>vy7+?!$^mB`KQ~Ls#y-F^ znfqA%4t5wAoT2clNqKuXDWYM1x)c8&+ts`nhexjhZg}3m^2;LZomzt1FVEaQ`Rr@# z4xy0voSPX^G7ixTUY&~>&$Z)Y`{CUTI%(IN^QE^&^jy>@&f)kkx3&9xaoLgTax7f! zmZ4YMG0pQ>w_F$6#*zRjr(Ij^M}^le9NgZC-^#&7*{YkdUIZ)jRX-c^WZTFCab}Ir zr-}wQBjpFmOOlvVa`?K;C&xx9dzVjk6!cI&r06yoZII4j0Q0y)p1;0g!F04@pUgMLT$1v_SY@<+ktD487^q)v~^nM z*IPkTmk2JQFLdWXoPBuglUC}`onmJed4k#Qjpq##0zO=q4q*}%BDO(nXb4A&CTozj z0R<_me3r$ttk(gY;M#u4H3->8;>Fi+IkptyBF`%yoNZaAA-Pu?YL`XjVr^qgm8}Z> z(?m@UR8Q$uQode;{yU3QsM9o>2movp-ih4EnF*d9ZsuBp2){5l5X6I)TB6y_ii>pg zyLEdWfAAT5RUv3XE01{OU6!!3m`hB&@$p+CLx@#*Px)Np-QGzB(0`Yu7L19cxyu}T zMBhqt1MLEkdqB|<pKO?PRHW>e2;lQ@45K=2Ydc6~P%&58wS7G{t>hB!_A&{?uqL zP4Q%R2od5J_$qv3T!i=kp&To(3w{%*-HMQV*Q3C-|Jdr77z4{a{(1B=n2hm#OO1if zZk0Wf_>Z`V`j|TOFoX^Aj4=zrHemSAiz`n7XerxKJ0^zLUNFP32WzRp@i)pHEMwZ1 zc8zEVd6q(w(=gX(98KTO6V~D{ivgF%0JTzp%#1K*321^u>HiNLMBiM*)*c8sN>cy+ O00Ui9ojNV&xc>pKgQV>M literal 0 HcmV?d00001 diff --git a/tests/FlexRender.Tests/Snapshots/golden/shapes_box_basic.png b/tests/FlexRender.Tests/Snapshots/golden/shapes_box_basic.png new file mode 100644 index 0000000000000000000000000000000000000000..643b2eb780ac22947d728e25c3c828d0adf06ca8 GIT binary patch literal 3875 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 Date: Sat, 13 Jun 2026 14:28:49 +0400 Subject: [PATCH 023/119] docs: document rect, circle, ellipse, draw shapes in llms files --- llms-full.txt | 140 +++++++++++++++++++++++++++++++++++++++++++++++++- llms.txt | 40 ++++++++++++++- 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index eebac7b..84cc616 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -623,7 +623,7 @@ layout: ## Flex-Item Properties -All leaf elements (text, image, qr, barcode, separator) and flex containers (when nested) have these flex-item properties: +All leaf elements (text, image, qr, barcode, separator, rect, circle, ellipse, draw) and flex containers (when nested) have these flex-item properties: | Property | Type | Default | Description | |----------|------|---------|-------------| @@ -824,6 +824,143 @@ Tabular data with configurable columns, optional header, and support for dynamic Horizontal separators stretch to full width and use thickness as height. Vertical separators stretch to full height and use thickness as width. The default style is `dotted`, chosen for the primary receipt printing use case. +## Shape Elements + +The `rect`, `circle`, and `ellipse` elements are **box shapes** -- flex boxes that are painted as a filled and/or stroked vector shape. They participate in flex layout exactly like any other box: they honor `width`, `height`, `margin`, `padding`, and all flex-item properties (`grow`, `shrink`, `basis`, `order`, `alignSelf`). The shape is drawn to fill the box content area. + +### Shared Box-Shape Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| fill | string OR object | none | Solid hex color (e.g. `"#4A90D9"`) OR a gradient object (see below) | +| stroke | string | none | Stroke (outline) color in hex | +| stroke-width | number | 0 | Stroke width in pixels | +| opacity | number | 1.0 | Inherited base opacity, `0..1` | +| radius | unit | none | Corner radius in px/em (**rect only**) | +| size | unit | none | Shorthand setting both width and height = diameter (**circle only**) | + +### Gradient Fill Object + +The `fill` property accepts an object form to paint a gradient. It is converted internally to a CSS gradient string. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| gradient | string | -- | `linear` or `radial` (required) | +| colors | string[] | -- | Two or more hex color stops (required, minimum 2) | +| angle | number | 0 | Angle in degrees (linear only; radial ignores `angle`) | + +```yaml +- type: rect + width: 100 + height: 100 + fill: + gradient: linear + colors: ["#f00", "#00f"] + angle: 45 +``` + +## Element Type: rect (RectElement) + +A flex box drawn as a filled/stroked rectangle, optionally with rounded corners. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| fill | string OR object | none | Solid hex color or gradient object | +| stroke | string | none | Stroke color in hex | +| stroke-width | number | 0 | Stroke width in pixels | +| opacity | number | 1.0 | Base opacity, `0..1` | +| radius | unit | none | Corner radius (px/em) | + +```yaml +- type: rect + width: 100 + height: 50 + fill: "#4A90D9" + stroke: "#333333" + stroke-width: 2 + radius: 4 +``` + +## Element Type: circle (CircleElement) + +A flex box drawn as a circle. Use `size` as a shorthand to set both width and height (the diameter); the circle is inscribed in the resulting box. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| fill | string OR object | none | Solid hex color or gradient object | +| stroke | string | none | Stroke color in hex | +| stroke-width | number | 0 | Stroke width in pixels | +| opacity | number | 1.0 | Base opacity, `0..1` | +| size | unit | none | Sets both width and height (diameter) | + +```yaml +- type: circle + size: 40 + fill: "#e74c3c" +``` + +## Element Type: ellipse (EllipseElement) + +A flex box drawn as an ellipse that fills its `width` by `height` box. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| fill | string OR object | none | Solid hex color or gradient object | +| stroke | string | none | Stroke color in hex | +| stroke-width | number | 0 | Stroke width in pixels | +| opacity | number | 1.0 | Base opacity, `0..1` | + +```yaml +- type: ellipse + width: 120 + height: 60 + fill: "#2ecc71" +``` + +## Element Type: draw (DrawElement) + +A flex box that holds an ordered list of absolute-coordinate `shapes`. Shapes are painted in list order (painter's algorithm -- later shapes are drawn on top). All coordinates are relative to the `draw` element's top-left corner. The number of shapes is capped by `ResourceLimits.MaxShapesPerDraw` (default 1000). + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| width | unit | none | Box width | +| height | unit | none | Box height | +| shapes | Shape[] | [] | Ordered list of shapes (painted in order) | + +Each entry in `shapes` is a single-key object whose key selects the shape kind: + +| Shape | Keys | Description | +|-------|------|-------------| +| line | `x1`, `y1`, `x2`, `y2`, `stroke`, `stroke-width` | Straight line segment | +| polyline | `points` (`[[x,y],...]`), `stroke`, `stroke-width`, `fill` | Connected line segments, optionally filled | +| rect | `x`, `y`, `width`, `height`, `fill`, `stroke`, `stroke-width`, `radius` | Rectangle (optional rounded corners) | +| circle | `cx`, `cy`, `r`, `fill`, `stroke`, `stroke-width` | Circle centered at (`cx`,`cy`) with radius `r` | +| path | `d`, `fill`, `stroke`, `stroke-width` | SVG-style path (see grammar below) | + +### Path `d` Grammar + +The `path` shape's `d` string supports **absolute commands only**: `M` (moveto), `L` (lineto), `Q` (quadratic Bézier), `C` (cubic Bézier), and `Z` (closepath). Lowercase/relative commands are rejected, and non-finite numbers are rejected. + +``` +M x y # move to (x, y) +L x y # line to (x, y) +Q cx cy x y # quadratic Bézier, control (cx, cy), end (x, y) +C c1x c1y c2x c2y x y # cubic Bézier, two controls, end point +Z # close path +``` + +Example: `d: "M 0 0 L 100 50 Q 150 0 200 50 Z"` + +```yaml +- type: draw + width: 400 + height: 200 + shapes: + - line: {x1: 0, y1: 100, x2: 400, y2: 50, stroke: "#333", stroke-width: 2} + - circle: {cx: 200, cy: 75, r: 30, fill: "#e74c3c"} + - path: {d: "M 0 0 L 100 50 Q 150 0 200 50 Z", fill: "#2ecc71"} +``` + ## Element Type: svg (SvgElement) Renders SVG vector graphics. Supports external files (`src`) or inline markup (`content`). These are mutually exclusive. @@ -1407,6 +1544,7 @@ The `ResourceLimits` class centralizes all security limits. All properties valid | MaxRenderDepth | int | 100 | Maximum recursion depth when rendering layout tree | | MaxImageSize | int | 10,485,760 (10 MB) | Maximum allowed image size in bytes | | MaxFlexLines | int | 1,000 | Maximum flex lines when wrapping | +| MaxShapesPerDraw | int | 1,000 | Maximum shapes in a single `draw` element | ### Direct Usage diff --git a/llms.txt b/llms.txt index 2187168..128e8d6 100644 --- a/llms.txt +++ b/llms.txt @@ -74,7 +74,7 @@ Templates can be parsed once and cached for reuse with different data. ## Element Types -Eleven element types, each a sealed class extending `TemplateElement`: +Fifteen element types, each a sealed class extending `TemplateElement`: | Type | Key Properties | |------|---------------| @@ -85,6 +85,10 @@ Eleven element types, each a sealed class extending `TemplateElement`: | **barcode** | data, format (code128/code39/ean13/ean8/upc), width, height, showText, foreground -- requires `FlexRender.Barcode.*` (Skia/Svg/ImageSharp) | | **svg** | src, content, width, height, fit (fill/contain/cover/none) -- requires `FlexRender.SvgElement.*` (Skia/Svg) | | **separator** | orientation (horizontal/vertical), style (dotted/dashed/solid), thickness, color | +| **rect** | fill (hex color OR gradient object), stroke, stroke-width (px, default 0), opacity (0..1), radius (px/em corner rounding) -- flex box drawn as a filled/stroked rectangle | +| **circle** | fill (hex color OR gradient object), stroke, stroke-width, opacity, size (shorthand setting width & height = diameter) -- flex box drawn as a circle | +| **ellipse** | fill (hex color OR gradient object), stroke, stroke-width, opacity -- flex box drawn as an ellipse filling its width/height | +| **draw** | shapes (ordered list painted in list order); shape kinds: line, polyline, rect, circle, path (abs commands M/L/Q/C/Z); absolute coords relative to element top-left; capped by `MaxShapesPerDraw` (default 1000). Box shapes accept gradient fill objects | | **table** | array, as, columns (key/label/width/grow/align/format), rows, headerFont/headerFontWeight/headerFontStyle/headerFontFamily/headerColor/headerSize/headerBackground -- expands to flex tree | | **each** | array, as, children -- iteration over data arrays | | **if** | condition, equals/notEquals, then, elseIf, else -- conditional rendering | @@ -355,6 +359,39 @@ layout: color: "#cccccc" thickness: 2 + - type: rect + width: 100 + height: 50 + fill: "#4A90D9" + stroke: "#333333" + stroke-width: 2 + radius: 4 + + - type: circle + size: 40 + fill: "#e74c3c" + + - type: ellipse + width: 120 + height: 60 + fill: "#2ecc71" + + - type: rect + width: 100 + height: 100 + fill: + gradient: linear + colors: ["#f00", "#00f"] + angle: 45 + + - type: draw + width: 400 + height: 200 + shapes: + - line: {x1: 0, y1: 100, x2: 400, y2: 50, stroke: "#333", stroke-width: 2} + - circle: {cx: 200, cy: 75, r: 30, fill: "#e74c3c"} + - path: {d: "M 0 0 L 100 50 Q 150 0 200 50 Z", fill: "#2ecc71"} + - type: flex direction: row gap: "10" @@ -378,6 +415,7 @@ The `ResourceLimits` class centralizes all security limits. Defaults are safe-by | MaxRenderDepth | 100 | Render tree recursion | | MaxImageSize | 10 MB | Image loading | | MaxFlexLines | 1000 | Maximum flex lines when wrapping | +| MaxShapesPerDraw | 1000 | Maximum shapes in a single `draw` element | Configure via builder: `builder.WithLimits(limits => { limits.MaxRenderDepth = 200; })` From cf1cf9dd00f4728a3ec534e5b4425c2ab38c0ed4 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 14:30:46 +0400 Subject: [PATCH 024/119] docs(wiki): add rect, circle, ellipse, draw to element and visual references --- docs/wiki/Element-Reference.md | 178 ++++++++++++++++++++++++++++++++- docs/wiki/Visual-Reference.md | 100 ++++++++++++++++++ 2 files changed, 277 insertions(+), 1 deletion(-) diff --git a/docs/wiki/Element-Reference.md b/docs/wiki/Element-Reference.md index d462cce..3e1f2d3 100644 --- a/docs/wiki/Element-Reference.md +++ b/docs/wiki/Element-Reference.md @@ -12,7 +12,7 @@ For rendering options (antialiasing, format settings), see [[Render-Options]]. ## Common Properties (TemplateElement) -All 11 element types (`flex`, `text`, `image`, `svg`, `qr`, `barcode`, `separator`, `table`, `content`, `each`, `if`) inherit these properties from the base `TemplateElement` class. You can use any of them on any element. +All 15 element types (`flex`, `text`, `image`, `svg`, `qr`, `barcode`, `separator`, `rect`, `circle`, `ellipse`, `draw`, `table`, `content`, `each`, `if`) inherit these properties from the base `TemplateElement` class. You can use any of them on any element. > **Expression support:** All properties on all element types accept `{{expressions}}`. This includes typed properties like `opacity` (float), `grow`/`shrink` (float), `order` (int), `wrap` (bool on text, FlexWrap on flex), and enum properties like `display`, `position`, `align`. See [[Template-Expressions]] for details. @@ -1281,6 +1281,182 @@ Horizontal separators use `thickness` as their height and stretch to the contain --- +## rect + +A flex box drawn as a filled and/or stroked rectangle, optionally with rounded corners. It participates in flex layout like any other box (honors `width`, `height`, `margin`, `padding`, and all flex-item properties); the rectangle fills the box content area. + +### Minimal Example + +```yaml +- type: rect + width: 100 + height: 50 + fill: "#4A90D9" +``` + +### Properties + +| Property | YAML Name | Type | Default | Valid Values | Required | Description | +|----------|-----------|------|---------|--------------|----------|-------------| +| Fill | `fill` | string or object | none | Hex color or gradient object | No | Solid color or gradient object `{gradient, colors, angle}`. See [Gradient Fill](#gradient-fill). | +| Stroke | `stroke` | string | none | Hex color (#rgb or #rrggbb) | No | Outline color. | +| Stroke Width | `stroke-width` | number | `0` | >= 0 | No | Outline width in pixels. | +| Opacity | `opacity` | number | `1.0` | 0..1 | No | Inherited base opacity. | +| Radius | `radius` | unit | none | px, em | No | Corner radius (rect only). | + +```yaml +- type: rect + width: 100 + height: 50 + fill: "#4A90D9" + stroke: "#333333" + stroke-width: 2 + radius: 4 +``` + +--- + +## circle + +A flex box drawn as a circle inscribed in its box. Use `size` as a shorthand to set both `width` and `height` (the diameter). + +### Minimal Example + +```yaml +- type: circle + size: 40 + fill: "#e74c3c" +``` + +### Properties + +| Property | YAML Name | Type | Default | Valid Values | Required | Description | +|----------|-----------|------|---------|--------------|----------|-------------| +| Fill | `fill` | string or object | none | Hex color or gradient object | No | Solid color or gradient object `{gradient, colors, angle}`. See [Gradient Fill](#gradient-fill). | +| Stroke | `stroke` | string | none | Hex color (#rgb or #rrggbb) | No | Outline color. | +| Stroke Width | `stroke-width` | number | `0` | >= 0 | No | Outline width in pixels. | +| Opacity | `opacity` | number | `1.0` | 0..1 | No | Inherited base opacity. | +| Size | `size` | unit | none | px, em | No | Sets both width and height (circle only). | + +```yaml +- type: circle + size: 60 + fill: "#e74c3c" + stroke: "#922b21" + stroke-width: 3 +``` + +--- + +## ellipse + +A flex box drawn as an ellipse that fills its `width` by `height` box. + +### Minimal Example + +```yaml +- type: ellipse + width: 120 + height: 60 + fill: "#2ecc71" +``` + +### Properties + +| Property | YAML Name | Type | Default | Valid Values | Required | Description | +|----------|-----------|------|---------|--------------|----------|-------------| +| Fill | `fill` | string or object | none | Hex color or gradient object | No | Solid color or gradient object `{gradient, colors, angle}`. See [Gradient Fill](#gradient-fill). | +| Stroke | `stroke` | string | none | Hex color (#rgb or #rrggbb) | No | Outline color. | +| Stroke Width | `stroke-width` | number | `0` | >= 0 | No | Outline width in pixels. | +| Opacity | `opacity` | number | `1.0` | 0..1 | No | Inherited base opacity. | + +```yaml +- type: ellipse + width: 120 + height: 60 + fill: "#2ecc71" +``` + +--- + +### Gradient Fill + +The `fill` property of `rect`, `circle`, and `ellipse` accepts an object form to paint a gradient instead of a solid color. It is converted internally to a CSS gradient string. + +| Property | YAML Name | Type | Default | Valid Values | Required | Description | +|----------|-----------|------|---------|--------------|----------|-------------| +| Gradient | `gradient` | string | -- | linear, radial | Yes | Gradient kind. | +| Colors | `colors` | string[] | -- | >= 2 hex colors | Yes | Ordered color stops (minimum 2). | +| Angle | `angle` | number | `0` | degrees | No | Angle in degrees (linear only; radial ignores it). | + +```yaml +- type: rect + width: 100 + height: 100 + fill: + gradient: linear + colors: ["#f00", "#00f"] + angle: 45 +``` + +--- + +## draw + +A flex box that holds an ordered list of absolute-coordinate `shapes`. Shapes are painted in list order using the painter's algorithm (later shapes are drawn on top of earlier ones). All shape coordinates are relative to the `draw` element's top-left corner. The number of shapes is capped by `ResourceLimits.MaxShapesPerDraw` (default `1000`). + +### Minimal Example + +```yaml +- type: draw + width: 400 + height: 200 + shapes: + - circle: {cx: 200, cy: 100, r: 50, fill: "#e74c3c"} +``` + +### Properties + +| Property | YAML Name | Type | Default | Valid Values | Required | Description | +|----------|-----------|------|---------|--------------|----------|-------------| +| Width | `width` | unit | none | px, %, em | No | Box width. | +| Height | `height` | unit | none | px, %, em | No | Box height. | +| Shapes | `shapes` | Shape[] | `[]` | List of shape objects | No | Ordered list, painted in order (max `MaxShapesPerDraw`, default 1000). | + +### Shape Kinds + +Each entry in `shapes` is a single-key object whose key selects the shape kind. Coordinates are absolute, relative to the `draw` element top-left. + +| Shape | Keys | Description | +|-------|------|-------------| +| `line` | `x1`, `y1`, `x2`, `y2`, `stroke`, `stroke-width` | Straight line segment. | +| `polyline` | `points` (`[[x,y],...]`), `stroke`, `stroke-width`, `fill` | Connected segments, optionally filled. | +| `rect` | `x`, `y`, `width`, `height`, `fill`, `stroke`, `stroke-width`, `radius` | Rectangle, optional rounded corners. | +| `circle` | `cx`, `cy`, `r`, `fill`, `stroke`, `stroke-width` | Circle centered at (`cx`, `cy`) with radius `r`. | +| `path` | `d`, `fill`, `stroke`, `stroke-width` | SVG-style path (see grammar below). | + +**Path `d` grammar:** absolute commands only -- `M` (moveto), `L` (lineto), `Q` (quadratic Bézier), `C` (cubic Bézier), `Z` (closepath). Lowercase/relative commands are rejected, and non-finite numbers are rejected. + +``` +M x y # move to +L x y # line to +Q cx cy x y # quadratic Bézier (control, end) +C c1x c1y c2x c2y x y # cubic Bézier (two controls, end) +Z # close path +``` + +```yaml +- type: draw + width: 400 + height: 200 + shapes: + - line: {x1: 0, y1: 100, x2: 400, y2: 50, stroke: "#333", stroke-width: 2} + - circle: {cx: 200, cy: 75, r: 30, fill: "#e74c3c"} + - path: {d: "M 0 0 L 100 50 Q 150 0 200 50 Z", fill: "#2ecc71"} +``` + +--- + ## table Renders tabular data with configurable columns, optional header row, and support for both dynamic (data-driven) and static rows. During template expansion, the `table` element is expanded into a tree of `flex` and `text` elements -- no changes to the layout or rendering engines are needed. diff --git a/docs/wiki/Visual-Reference.md b/docs/wiki/Visual-Reference.md index dac8563..97ff2ca 100644 --- a/docs/wiki/Visual-Reference.md +++ b/docs/wiki/Visual-Reference.md @@ -30,6 +30,9 @@ A comprehensive visual guide with rendered examples for all FlexRender propertie - [qr](#qr) - [barcode](#barcode) - [svg](#svg) + - [Shapes (rect, circle, ellipse)](#shapes-rect-circle-ellipse) + - [Gradient Fills](#gradient-fills) + - [draw](#draw) - [See Also](#see-also) --- @@ -634,6 +637,103 @@ Same as `image` fit: `fill` (stretch), `contain` (fit within bounds), `cover` (f --- +### Shapes (rect, circle, ellipse) + +The `rect`, `circle`, and `ellipse` elements are flex boxes painted as filled and/or stroked vector shapes. They take part in flex layout like any other box. `circle` accepts a `size` shorthand (diameter), and `rect` supports a `radius` for rounded corners. + +```yaml +canvas: + width: 260 + height: 90 + fixed: both + background: "#ffffff" +layout: + - type: flex + direction: row + gap: "10" + padding: "10" + align: center + children: + - type: rect + width: 70 + height: 50 + fill: "#4A90D9" + stroke: "#1f3a5f" + stroke-width: 2 + radius: 6 + - type: circle + size: 50 + fill: "#e74c3c" + - type: ellipse + width: 80 + height: 50 + fill: "#2ecc71" + stroke: "#145a32" + stroke-width: 2 +``` + +![Box shapes: rect, circle, ellipse](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/tests/FlexRender.Tests/Snapshots/golden/shapes_box_basic.png) + +#### Gradient Fills + +The `fill` property accepts a gradient object `{gradient, colors, angle}` instead of a solid hex color. `gradient` is `linear` or `radial`; `colors` needs at least two stops; `angle` (degrees) applies to linear gradients only. + +```yaml +canvas: + width: 220 + height: 110 + fixed: both + background: "#ffffff" +layout: + - type: flex + direction: row + gap: "10" + padding: "10" + children: + - type: rect + width: 90 + height: 90 + fill: + gradient: linear + colors: ["#ff0000", "#0000ff"] + angle: 45 + - type: circle + size: 90 + fill: + gradient: radial + colors: ["#ffffff", "#222222"] +``` + +![Gradient fills on box shapes](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/tests/FlexRender.Tests/Snapshots/golden/shapes_gradient.png) + +--- + +### draw + +The `draw` element is a flex box holding an ordered list of absolute-coordinate `shapes`, painted in list order (painter's algorithm). Supported shape kinds are `line`, `polyline`, `rect`, `circle`, and `path`. Path `d` strings use absolute commands only (`M`, `L`, `Q`, `C`, `Z`). The number of shapes is capped by `ResourceLimits.MaxShapesPerDraw` (default 1000). + +```yaml +canvas: + width: 200 + height: 160 + fixed: both + background: "#ffffff" +layout: + - type: draw + width: 200 + height: 160 + shapes: + - rect: {x: 20, y: 20, width: 120, height: 80, fill: "#cccccc", radius: 8} + - line: {x1: 0, y1: 80, x2: 200, y2: 40, stroke: "#333333", stroke-width: 3} + - polyline: {points: [[10, 140], [60, 110], [110, 130], [160, 100]], stroke: "#4A90D9", stroke-width: 2} + - circle: {cx: 130, cy: 70, r: 35, fill: "#e74c3c"} + - path: {d: "M 20 150 L 80 110 Q 120 90 160 120 Z", fill: "#2ecc71"} +``` + +![Draw element with overlapping shapes in painter's order](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/tests/FlexRender.Tests/Snapshots/golden/draw_overlap.png) + +--- + ## Renderer Backend Comparison FlexRender supports three rendering backends. The same template produces visually similar output across all backends, with some backend-specific features: From 404d4cbf9645baf7cb734a40abb37613c7fed2f2 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 14:31:54 +0400 Subject: [PATCH 025/119] docs(playground): add shape and draw schemas to template json --- .../wwwroot/schemas/flexrender-template.json | 228 +++++++++++++++++- 1 file changed, 227 insertions(+), 1 deletion(-) diff --git a/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json b/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json index 91c516c..a5b7301 100644 --- a/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json +++ b/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json @@ -171,13 +171,41 @@ "required": ["type"] }, "then": { "$ref": "#/definitions/contentElement" } + }, + { + "if": { + "properties": { "type": { "const": "rect" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/rectElement" } + }, + { + "if": { + "properties": { "type": { "const": "circle" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/circleElement" } + }, + { + "if": { + "properties": { "type": { "const": "ellipse" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/ellipseElement" } + }, + { + "if": { + "properties": { "type": { "const": "draw" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/drawElement" } } ], "properties": { "type": { "description": "The element type.", "type": "string", - "enum": ["text", "flex", "image", "qr", "barcode", "separator", "svg", "table", "each", "if", "content"] + "enum": ["text", "flex", "image", "qr", "barcode", "separator", "svg", "table", "each", "if", "content", "rect", "circle", "ellipse", "draw"] } } }, @@ -637,6 +665,204 @@ } }, + "gradientFill": { + "description": "A gradient fill object for shape elements.", + "type": "object", + "required": ["gradient", "colors"], + "properties": { + "gradient": { + "description": "Gradient kind.", + "type": "string", + "enum": ["linear", "radial"] + }, + "colors": { + "description": "Ordered color stops (minimum two hex strings).", + "type": "array", + "items": { "type": "string" }, + "minItems": 2 + }, + "angle": { + "description": "Gradient angle in degrees (linear only; radial ignores it).", + "type": "number" + } + } + }, + + "rectElement": { + "description": "A flex box drawn as a filled and/or stroked rectangle.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "rect" }, + "fill": { + "description": "Solid hex color or a gradient fill object.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/gradientFill" } + ] + }, + "stroke": { + "description": "Stroke (outline) color (hex string).", + "type": "string" + }, + "stroke-width": { + "description": "Stroke width in pixels.", + "type": "number", + "minimum": 0 + }, + "radius": { + "description": "Corner radius (px or em).", + "type": ["number", "string"] + } + } + }, + + "circleElement": { + "description": "A flex box drawn as a circle.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "circle" }, + "fill": { + "description": "Solid hex color or a gradient fill object.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/gradientFill" } + ] + }, + "stroke": { + "description": "Stroke (outline) color (hex string).", + "type": "string" + }, + "stroke-width": { + "description": "Stroke width in pixels.", + "type": "number", + "minimum": 0 + }, + "size": { + "description": "Shorthand setting both width and height (the diameter).", + "type": ["number", "string"] + } + } + }, + + "ellipseElement": { + "description": "A flex box drawn as an ellipse filling its width and height.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "ellipse" }, + "fill": { + "description": "Solid hex color or a gradient fill object.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/gradientFill" } + ] + }, + "stroke": { + "description": "Stroke (outline) color (hex string).", + "type": "string" + }, + "stroke-width": { + "description": "Stroke width in pixels.", + "type": "number", + "minimum": 0 + } + } + }, + + "drawShape": { + "description": "A single shape in a draw element. Use exactly one shape key.", + "type": "object", + "properties": { + "line": { + "description": "A straight line segment.", + "type": "object", + "properties": { + "x1": { "type": "number" }, + "y1": { "type": "number" }, + "x2": { "type": "number" }, + "y2": { "type": "number" }, + "stroke": { "type": "string" }, + "stroke-width": { "type": "number", "minimum": 0 } + } + }, + "polyline": { + "description": "Connected line segments, optionally filled.", + "type": "object", + "properties": { + "points": { + "description": "List of [x, y] points.", + "type": "array", + "items": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + } + }, + "stroke": { "type": "string" }, + "stroke-width": { "type": "number", "minimum": 0 }, + "fill": { "type": "string" } + } + }, + "rect": { + "description": "A rectangle, optionally with rounded corners.", + "type": "object", + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" }, + "width": { "type": "number" }, + "height": { "type": "number" }, + "fill": { "type": "string" }, + "stroke": { "type": "string" }, + "stroke-width": { "type": "number", "minimum": 0 }, + "radius": { "type": "number", "minimum": 0 } + } + }, + "circle": { + "description": "A circle centered at (cx, cy) with radius r.", + "type": "object", + "properties": { + "cx": { "type": "number" }, + "cy": { "type": "number" }, + "r": { "type": "number" }, + "fill": { "type": "string" }, + "stroke": { "type": "string" }, + "stroke-width": { "type": "number", "minimum": 0 } + } + }, + "path": { + "description": "An SVG-style path. The 'd' string supports absolute commands M, L, Q, C, Z only.", + "type": "object", + "properties": { + "d": { "type": "string" }, + "fill": { "type": "string" }, + "stroke": { "type": "string" }, + "stroke-width": { "type": "number", "minimum": 0 } + } + } + } + }, + + "drawElement": { + "description": "A flex box holding an ordered list of absolute-coordinate shapes painted in list order.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "draw" }, + "shapes": { + "description": "Ordered list of shapes, painted in order (max MaxShapesPerDraw, default 1000).", + "type": "array", + "items": { "$ref": "#/definitions/drawShape" } + } + } + }, + "svgElement": { "description": "Renders an SVG image from a source path or inline content.", "allOf": [ From 2ff439701606cd719a9ebf6ac16343a8487669d6 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 14:37:50 +0400 Subject: [PATCH 026/119] docs(wiki): host shape doc images under examples/visual-docs, add yaml sources --- docs/wiki/Visual-Reference.md | 6 ++-- .../visual-docs/output/shapes-box-basic.png | Bin 0 -> 3875 bytes .../output/shapes-draw-overlap.png | Bin 0 -> 4137 bytes .../visual-docs/output/shapes-gradient.png | Bin 0 -> 5154 bytes examples/visual-docs/shapes/box-basic.yaml | 33 ++++++++++++++++++ examples/visual-docs/shapes/draw-overlap.yaml | 20 +++++++++++ examples/visual-docs/shapes/gradient.yaml | 28 +++++++++++++++ 7 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 examples/visual-docs/output/shapes-box-basic.png create mode 100644 examples/visual-docs/output/shapes-draw-overlap.png create mode 100644 examples/visual-docs/output/shapes-gradient.png create mode 100644 examples/visual-docs/shapes/box-basic.yaml create mode 100644 examples/visual-docs/shapes/draw-overlap.yaml create mode 100644 examples/visual-docs/shapes/gradient.yaml diff --git a/docs/wiki/Visual-Reference.md b/docs/wiki/Visual-Reference.md index 97ff2ca..abc66b2 100644 --- a/docs/wiki/Visual-Reference.md +++ b/docs/wiki/Visual-Reference.md @@ -672,7 +672,7 @@ layout: stroke-width: 2 ``` -![Box shapes: rect, circle, ellipse](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/tests/FlexRender.Tests/Snapshots/golden/shapes_box_basic.png) +![Box shapes: rect, circle, ellipse](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/shapes-box-basic.png) #### Gradient Fills @@ -704,7 +704,7 @@ layout: colors: ["#ffffff", "#222222"] ``` -![Gradient fills on box shapes](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/tests/FlexRender.Tests/Snapshots/golden/shapes_gradient.png) +![Gradient fills on box shapes](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/shapes-gradient.png) --- @@ -730,7 +730,7 @@ layout: - path: {d: "M 20 150 L 80 110 Q 120 90 160 120 Z", fill: "#2ecc71"} ``` -![Draw element with overlapping shapes in painter's order](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/tests/FlexRender.Tests/Snapshots/golden/draw_overlap.png) +![Draw element with overlapping shapes in painter's order](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/shapes-draw-overlap.png) --- diff --git a/examples/visual-docs/output/shapes-box-basic.png b/examples/visual-docs/output/shapes-box-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..643b2eb780ac22947d728e25c3c828d0adf06ca8 GIT binary patch literal 3875 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 O00Ui9ojNV&xc>pKgQV>M literal 0 HcmV?d00001 diff --git a/examples/visual-docs/output/shapes-gradient.png b/examples/visual-docs/output/shapes-gradient.png new file mode 100644 index 0000000000000000000000000000000000000000..52675651e2474837fe27d437c8fc59e40fa9578c GIT binary patch literal 5154 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 Date: Sat, 13 Jun 2026 14:39:06 +0400 Subject: [PATCH 027/119] docs: record post-Phase-1 follow-ups in shapes plan --- docs/superpowers/plans/2026-06-12-shapes-phase1.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/superpowers/plans/2026-06-12-shapes-phase1.md b/docs/superpowers/plans/2026-06-12-shapes-phase1.md index b5ac7a9..2c25497 100644 --- a/docs/superpowers/plans/2026-06-12-shapes-phase1.md +++ b/docs/superpowers/plans/2026-06-12-shapes-phase1.md @@ -3470,6 +3470,11 @@ gh pr create --base main --head feature/charts-and-shapes \ ## Follow-up (separate repo) - Update \`flexrender/skills/template/SKILL.md\` in RoboNET/FlexRender-Marketplace for the new elements, gradient object form, and MaxShapesPerDraw. +## Follow-up (this repo, post-Phase-1) +- Inner draw-shape property typo validation: properties inside a shape mapping (\`x1\`, \`cx\`, \`d\`, \`stroke-width\`, \`r\`, etc.) are not registered in \`KnownProperties\`, so a typo like \`strok\` is silently ignored instead of suggesting \`stroke\`. Add per-shape-kind known-property sets (line/polyline/rect/circle/path) and nested validation so inner typos surface, matching the typo-suggestion guarantee the top-level element properties already provide. +- A shape mapping with multiple recognized keys (e.g. \`{line: {...}, circle: {...}}\`) silently uses the first; consider rejecting >1 shape key per entry. +- Circle/ellipse stroke straddles the inscribed boundary (half the stroke width extends past the diameter). Standard SVG stroke behavior; document or add an inset option if exact-fit strokes are needed. + ## Out of scope Charts (later phases), SVG backend for shapes. From 9edef9241ce4ce6662bf52bf1dc6ccd62a5c816a Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 14:46:16 +0400 Subject: [PATCH 028/119] fix(parser): reject non-finite coordinates in draw shapes --- src/FlexRender.Yaml/Parsing/ShapeParsers.cs | 60 +++++++++++++------ .../Parsing/DrawParserTests.cs | 49 +++++++++++++++ 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/FlexRender.Yaml/Parsing/ShapeParsers.cs b/src/FlexRender.Yaml/Parsing/ShapeParsers.cs index 7dcdb54..0b837b9 100644 --- a/src/FlexRender.Yaml/Parsing/ShapeParsers.cs +++ b/src/FlexRender.Yaml/Parsing/ShapeParsers.cs @@ -252,6 +252,26 @@ private static DrawShape ParseDrawShape(YamlMappingNode node) "Unknown shape in 'draw' element. Each shape must be one of: 'line', 'polyline', 'rect', 'circle', 'path'."); } + /// + /// Reads a finite float property from the mapping, rejecting NaN and infinity + /// so degenerate geometry never reaches the renderer. + /// + /// The shape mapping. + /// The property key. + /// Value used when the key is absent. + /// The parsed finite float. + /// Thrown when the value is non-finite. + private static float GetFiniteFloatValue(YamlMappingNode node, string key, float defaultValue) + { + var value = GetFloatValue(node, key, defaultValue); + if (!float.IsFinite(value)) + { + throw new TemplateParseException( + $"Shape property '{key}' must be a finite number, but was '{value}'."); + } + return value; + } + /// /// Parses a line shape. /// @@ -259,12 +279,12 @@ private static DrawShape ParseDrawShape(YamlMappingNode node) /// The parsed . private static DrawLine ParseDrawLine(YamlMappingNode node) => new( - GetFloatValue(node, "x1", 0f), - GetFloatValue(node, "y1", 0f), - GetFloatValue(node, "x2", 0f), - GetFloatValue(node, "y2", 0f), + GetFiniteFloatValue(node, "x1", 0f), + GetFiniteFloatValue(node, "y1", 0f), + GetFiniteFloatValue(node, "x2", 0f), + GetFiniteFloatValue(node, "y2", 0f), GetStringValue(node, "stroke"), - GetFloatValue(node, "stroke-width", 1f)); + GetFiniteFloatValue(node, "stroke-width", 1f)); /// /// Parses a polyline shape. @@ -275,7 +295,7 @@ private static DrawPolyline ParseDrawPolyline(YamlMappingNode node) => new( ParsePoints(node), GetStringValue(node, "stroke"), - GetFloatValue(node, "stroke-width", 1f), + GetFiniteFloatValue(node, "stroke-width", 1f), GetStringValue(node, "fill")); /// @@ -285,14 +305,14 @@ private static DrawPolyline ParseDrawPolyline(YamlMappingNode node) /// The parsed . private static DrawRect ParseDrawRect(YamlMappingNode node) => new( - GetFloatValue(node, "x", 0f), - GetFloatValue(node, "y", 0f), - GetFloatValue(node, "width", 0f), - GetFloatValue(node, "height", 0f), + GetFiniteFloatValue(node, "x", 0f), + GetFiniteFloatValue(node, "y", 0f), + GetFiniteFloatValue(node, "width", 0f), + GetFiniteFloatValue(node, "height", 0f), GetStringValue(node, "fill"), GetStringValue(node, "stroke"), - GetFloatValue(node, "stroke-width", 0f), - GetFloatValue(node, "radius", 0f)); + GetFiniteFloatValue(node, "stroke-width", 0f), + GetFiniteFloatValue(node, "radius", 0f)); /// /// Parses a circle shape (absolute coordinates). @@ -301,12 +321,12 @@ private static DrawRect ParseDrawRect(YamlMappingNode node) /// The parsed . private static DrawCircle ParseDrawCircle(YamlMappingNode node) => new( - GetFloatValue(node, "cx", 0f), - GetFloatValue(node, "cy", 0f), - GetFloatValue(node, "r", 0f), + GetFiniteFloatValue(node, "cx", 0f), + GetFiniteFloatValue(node, "cy", 0f), + GetFiniteFloatValue(node, "r", 0f), GetStringValue(node, "fill"), GetStringValue(node, "stroke"), - GetFloatValue(node, "stroke-width", 0f)); + GetFiniteFloatValue(node, "stroke-width", 0f)); /// /// Parses a path shape, tokenizing its d attribute via . @@ -332,7 +352,7 @@ private static DrawPath ParseDrawPath(YamlMappingNode node) commands, GetStringValue(node, "fill"), GetStringValue(node, "stroke"), - GetFloatValue(node, "stroke-width", 0f)); + GetFiniteFloatValue(node, "stroke-width", 0f)); } /// @@ -361,6 +381,12 @@ private static List ParsePoints(YamlMappingNode node) "Each polyline point must be a [x, y] pair of numbers."); } + if (!float.IsFinite(x) || !float.IsFinite(y)) + { + throw new TemplateParseException( + $"Polyline point ({x}, {y}) must have finite coordinates."); + } + points.Add(new PathPoint(x, y)); } diff --git a/tests/FlexRender.Tests/Parsing/DrawParserTests.cs b/tests/FlexRender.Tests/Parsing/DrawParserTests.cs index 9641f17..151966a 100644 --- a/tests/FlexRender.Tests/Parsing/DrawParserTests.cs +++ b/tests/FlexRender.Tests/Parsing/DrawParserTests.cs @@ -164,4 +164,53 @@ public void Parse_Draw_MalformedPolylinePoints_Throws() """; Assert.Throws(() => _parser.Parse(yaml)); } + + [Fact] + public void Parse_Draw_NonFiniteCircleCoordinate_Throws() + { + var yaml = """ + canvas: + width: 200 + layout: + - type: draw + width: 200 + height: 150 + shapes: + - circle: {cx: 1e999, cy: 75, r: 30, fill: "#e74c3c"} + """; + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("finite", ex.Message); + } + + [Fact] + public void Parse_Draw_NonFiniteRectDimension_Throws() + { + var yaml = """ + canvas: + width: 200 + layout: + - type: draw + width: 200 + height: 150 + shapes: + - rect: {x: 0, y: 0, width: 1e999, height: 10, fill: "#ccc"} + """; + Assert.Throws(() => _parser.Parse(yaml)); + } + + [Fact] + public void Parse_Draw_NonFinitePolylinePoint_Throws() + { + var yaml = """ + canvas: + width: 200 + layout: + - type: draw + width: 200 + height: 150 + shapes: + - polyline: {points: [[0, 0], [1e999, 10]], stroke: "#333"} + """; + Assert.Throws(() => _parser.Parse(yaml)); + } } From 85559c5293b533093281dc30b82925a87407297c Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 14:46:58 +0400 Subject: [PATCH 029/119] docs: record holistic-review follow-ups and non-finite guard in shapes plan --- docs/superpowers/plans/2026-06-12-shapes-phase1.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/superpowers/plans/2026-06-12-shapes-phase1.md b/docs/superpowers/plans/2026-06-12-shapes-phase1.md index 2c25497..93d4993 100644 --- a/docs/superpowers/plans/2026-06-12-shapes-phase1.md +++ b/docs/superpowers/plans/2026-06-12-shapes-phase1.md @@ -3474,6 +3474,12 @@ gh pr create --base main --head feature/charts-and-shapes \ - Inner draw-shape property typo validation: properties inside a shape mapping (\`x1\`, \`cx\`, \`d\`, \`stroke-width\`, \`r\`, etc.) are not registered in \`KnownProperties\`, so a typo like \`strok\` is silently ignored instead of suggesting \`stroke\`. Add per-shape-kind known-property sets (line/polyline/rect/circle/path) and nested validation so inner typos surface, matching the typo-suggestion guarantee the top-level element properties already provide. - A shape mapping with multiple recognized keys (e.g. \`{line: {...}, circle: {...}}\`) silently uses the first; consider rejecting >1 shape key per entry. - Circle/ellipse stroke straddles the inscribed boundary (half the stroke width extends past the diameter). Standard SVG stroke behavior; document or add an inset option if exact-fit strokes are needed. +- Path-command ceiling: \`ParseDrawPath\` calls \`PathDataParser.Parse(d)\` with the hard-coded default \`maxCommands\` (10000) rather than a value from \`ResourceLimits\`. Worst case per draw element is \`MaxShapesPerDraw\` × 10000 commands. Bounded, not unbounded, but inconsistent with every other configurable limit — add a \`ResourceLimits.MaxPathCommands\` and thread it through. +- Silent stroke degradation: box \`rect\`/\`circle\`/\`ellipse\` and draw \`rect\`/\`circle\`/\`path\` default \`stroke-width\` to 0, and \`TryCreateStrokePaint\` requires width > 0, so \`stroke: "#333"\` with no \`stroke-width\` renders no stroke and no diagnostic (draw \`line\`/\`polyline\` default to 1). Inconsistent; decide on a default or warn. +- \`each\` + shape integration test: prove a \`rect\`/\`circle\` inside an \`each\` loop with \`{{item.color}}\` in \`fill\`/\`stroke\` resolves per-iteration (relies on \`ResolveExpressions\`, same as \`TextElement.Color\`). Common LLM pattern (bar charts, grids) — lock it in. + +## Done (post-review hardening, in this branch) +- Non-finite coordinate guard: structured draw-shape literal floats and polyline points now reject NaN/Infinity/overflow via \`GetFiniteFloatValue\`, matching \`PathDataParser\`'s finiteness check (commit fix(parser): reject non-finite coordinates in draw shapes). ## Out of scope Charts (later phases), SVG backend for shapes. From e4e69fd9c51f20ab9cf00cc9f77004a8ae8ac764 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:40:11 +0400 Subject: [PATCH 030/119] docs: add charts phase 2 implementation plan --- .../plans/2026-06-13-charts-phase2.md | 4303 +++++++++++++++++ 1 file changed, 4303 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-13-charts-phase2.md diff --git a/docs/superpowers/plans/2026-06-13-charts-phase2.md b/docs/superpowers/plans/2026-06-13-charts-phase2.md new file mode 100644 index 0000000..cc4698c --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-charts-phase2.md @@ -0,0 +1,4303 @@ +# Charts (Phase 2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a declarative `chart` element (chart-types `bar` vertical/`horizontal`, `line`, `area`, `pie`, `donut`) with themes/palettes, axes (nice ticks), grid, legend, title, and a "no data" placeholder — so LLM agents can render polished charts from data arrays with zero styling decisions. + +**Architecture:** A new `ChartElement` AST class in `FlexRender.Core/Parsing/Ast/` follows the Phase-1 shape pattern (leaf box; overrides `Type`, `ResolveExpressions`, `Materialize`, `CloneWithSubstitution`) and carries pre-resolved numeric series. Series-data binding resolves a `{{ expr }}` to an `ArrayValue` inside `ChartElement.ResolveExpressions` via `ExpressionEvaluator.Resolve` against the data context, converting `NumberValue` items to `double[]`. Pure, renderer-agnostic chart math (nice-tick axis scaling) and the static theme/palette tables live in a new `FlexRender.Core/Charts/` namespace, heavily unit-tested. Parsing extends `FlexRender.Yaml` (`ChartParsers.cs`, `TemplateParser` dispatch, `KnownProperties`). Layout treats the chart as a leaf box with explicit width/height (reusing `MeasureShapeIntrinsic`/`LayoutShapeElement`). A new `ChartRenderer` in `FlexRender.Skia.Render` computes the plot area (minus title/legend/axes), draws grid+axes+labels then per-type geometry then legend, dispatched from `RenderingEngine.DrawElement` via a `case ChartElement`. Label text uses SkiaSharp `SKFont`/`SKPaint` built from an `SKTypeface` obtained from `FontManager`. + +**Tech Stack:** .NET 10, C# latest, xUnit, SkiaSharp, YamlDotNet. AOT-safe (no reflection, no `dynamic`, no regex), `sealed` classes, `ArgumentNullException.ThrowIfNull`, switch-based dispatch, XML docs on all public API. + +--- + +## Conventions used throughout this plan + +- All commands run from repo root `/Users/robonet/Projects/SkiaLayout`. +- Branch is already `feature/charts-and-shapes`. Do NOT create worktrees. Do NOT merge to `main`. +- Build: `dotnet build FlexRender.slnx`. Test (authoritative, net10.0): `dotnet test FlexRender.slnx --framework net10.0`. The net8.0 test host cannot launch in this environment — always pass `--framework net10.0` to test commands. +- NEVER pipe `dotnet` output through `tail`/`head`/`grep`. Run commands directly. +- Commit messages use Conventional Commits, NO attribution/Co-Authored-By lines. The signing key is missing — every commit uses `--no-gpg-sign`. +- After every code edit the build must be warning-free (`TreatWarningsAsErrors=true`). +- `ExprValue` materialize quirk (from Phase 1): for a string that may hold a color OR another form (e.g. a palette word or a label), materialize WITHOUT `ValueKind.Color` so validation does not reject non-hex strings. Series `Data` is never a color, palette/title/label are never colors. + +## File structure (created/modified across all tasks) + +Created: +- `src/FlexRender.Core/Charts/ChartEnums.cs` (ChartType, LegendPosition, PieLabelMode) +- `src/FlexRender.Core/Charts/AxisScale.cs` (nice-tick math) +- `src/FlexRender.Core/Charts/ChartTheme.cs` + `ChartThemes.cs` (static theme data) +- `src/FlexRender.Core/Charts/ChartPalette.cs` + `ChartPalettes.cs` (static palette data) +- `src/FlexRender.Core/Parsing/Ast/ChartSeries.cs` +- `src/FlexRender.Core/Parsing/Ast/ChartElement.cs` +- `src/FlexRender.Yaml/Parsing/ChartParsers.cs` +- `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Test files (see each task) + +Modified: +- `src/FlexRender.Core/Parsing/Ast/TemplateElement.cs` (add `ElementType.Chart`) +- `src/FlexRender.Core/Configuration/ResourceLimits.cs` (add `MaxSeriesPerChart`, `MaxDataPointsPerSeries`) +- `src/FlexRender.Core/Layout/IntrinsicMeasurer.cs` (add `ChartElement` to shape-intrinsic switch arm) +- `src/FlexRender.Core/Layout/LayoutEngine.cs` (add `ChartElement` to shape-layout switch arm) +- `src/FlexRender.Yaml/Parsing/TemplateParser.cs` (register `chart`) +- `src/FlexRender.Yaml/Parsing/KnownProperties.cs` (Chart property set + registry entry) +- `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs` (dispatch `ChartElement`) +- Docs: `llms.txt`, `llms-full.txt`, `docs/wiki/Element-Reference.md`, `docs/wiki/Visual-Reference.md`, + `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`, + `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` + +## Highest-risk tasks (flagged for extra review) + +1. **Task 16 — series-data expression → array binding** (`ChartElement.ResolveExpressions`). Resolves `{{ expr }}` to an `ArrayValue` and converts to `double[]`; non-numeric items must raise a clear template error with element context. Verify against the real `ExpressionEvaluator.Resolve`/`TemplateContext` API. +2. **Task 3–7 — axis nice-tick math** (`AxisScale`). Edge cases (crossing zero, negative-only, single point, identical values, empty) must be exhaustively unit-tested; this is renderer-agnostic and must be bullet-proof before any rendering. +3. **Task 19+ — label/text rendering integration** in `ChartRenderer`. Uses raw `SKFont`/`SKPaint` from a `FontManager` typeface; must measure+draw axis/legend/title labels and degrade gracefully (skip labels) when no typeface is available. + +--- + +## Task 1: ResourceLimits — MaxSeriesPerChart and MaxDataPointsPerSeries + +**Files:** +- Modify: `src/FlexRender.Core/Configuration/ResourceLimits.cs` +- Test: `tests/FlexRender.Tests/Configuration/ResourceLimitsChartsTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Configuration/ResourceLimitsChartsTests.cs`: + +```csharp +using System; +using FlexRender.Configuration; +using Xunit; + +namespace FlexRender.Tests.Configuration; + +/// +/// Tests for the chart-related resource limits. +/// +public sealed class ResourceLimitsChartsTests +{ + [Fact] + public void MaxSeriesPerChart_DefaultsTo50() + { + var limits = new ResourceLimits(); + Assert.Equal(50, limits.MaxSeriesPerChart); + } + + [Fact] + public void MaxDataPointsPerSeries_DefaultsTo10000() + { + var limits = new ResourceLimits(); + Assert.Equal(10000, limits.MaxDataPointsPerSeries); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MaxSeriesPerChart_RejectsNonPositive(int value) + { + var limits = new ResourceLimits(); + Assert.Throws(() => limits.MaxSeriesPerChart = value); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MaxDataPointsPerSeries_RejectsNonPositive(int value) + { + var limits = new ResourceLimits(); + Assert.Throws(() => limits.MaxDataPointsPerSeries = value); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ResourceLimitsChartsTests"` +Expected: BUILD FAILURE — `ResourceLimits` has no `MaxSeriesPerChart`/`MaxDataPointsPerSeries`. + +- [ ] **Step 3: Add the properties** + +In `src/FlexRender.Core/Configuration/ResourceLimits.cs`, add two backing fields next to `_maxShapesPerDraw`: + +```csharp + private int _maxSeriesPerChart = 50; + private int _maxDataPointsPerSeries = 10000; +``` + +Then add these two properties after the `MaxShapesPerDraw` property: + +```csharp + /// + /// Maximum number of data series allowed in a single 'chart' element. + /// Prevents resource exhaustion from templates with an unbounded series list. + /// + /// Default: 50. + /// Thrown when value is zero or negative. + public int MaxSeriesPerChart + { + get => _maxSeriesPerChart; + set + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + _maxSeriesPerChart = value; + } + } + + /// + /// Maximum number of data points allowed in a single chart series. + /// Prevents resource exhaustion from templates with an enormous data array. + /// + /// Default: 10000. + /// Thrown when value is zero or negative. + public int MaxDataPointsPerSeries + { + get => _maxDataPointsPerSeries; + set + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + _maxDataPointsPerSeries = value; + } + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ResourceLimitsChartsTests"` +Expected: PASS (6 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Configuration/ResourceLimits.cs tests/FlexRender.Tests/Configuration/ResourceLimitsChartsTests.cs +git commit --no-gpg-sign -m "feat(core): add MaxSeriesPerChart and MaxDataPointsPerSeries limits" +``` + +--- + +## Task 2: ElementType.Chart enum member + +**Files:** +- Modify: `src/FlexRender.Core/Parsing/Ast/TemplateElement.cs` + +- [ ] **Step 1: Add the enum member** + +In `src/FlexRender.Core/Parsing/Ast/TemplateElement.cs`, inside the `ElementType` enum, after the `Draw` member add a comma and: + +```csharp + /// + /// A chart element (bar, line, area, pie, donut). + /// + Chart +``` + +The enum tail must read `... Draw,` then the new `Chart` member. + +- [ ] **Step 2: Verify the build compiles** + +Run: `dotnet build FlexRender.slnx` +Expected: BUILD SUCCEEDED (enum-only extension, no behaviour change). + +- [ ] **Step 3: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Ast/TemplateElement.cs +git commit --no-gpg-sign -m "feat(ast): add Chart element type" +``` + +--- + +## Task 3: AxisScale — nice-tick math (data-driven happy path) + +This is renderer-agnostic pure math. Build it with exhaustive edge-case tests BEFORE anything draws. + +**Files:** +- Create: `src/FlexRender.Core/Charts/AxisScale.cs` +- Test: `tests/FlexRender.Tests/Charts/AxisScaleTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Charts/AxisScaleTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for the renderer-agnostic nice-tick axis scaling math. +/// +public sealed class AxisScaleTests +{ + [Fact] + public void Compute_SimplePositiveRange_ProducesNiceBounds() + { + var scale = AxisScale.Compute(0d, 48d, targetTicks: 5); + + Assert.Equal(0d, scale.Min); + Assert.Equal(50d, scale.Max); + Assert.Equal(10d, scale.Step); + Assert.Equal(new[] { 0d, 10d, 20d, 30d, 40d, 50d }, scale.Ticks); + } + + [Fact] + public void Compute_RangeNotStartingAtZero_StillIncludesZeroForBars() + { + // Bars/area need a zero baseline: min is clamped to 0 when data is all positive. + var scale = AxisScale.Compute(12d, 48d, targetTicks: 5); + + Assert.Equal(0d, scale.Min); + Assert.True(scale.Max >= 48d); + } + + [Fact] + public void Compute_NegativeOnly_ClampsMaxToZero() + { + var scale = AxisScale.Compute(-80d, -10d, targetTicks: 5); + + Assert.True(scale.Min <= -80d); + Assert.Equal(0d, scale.Max); + } + + [Fact] + public void Compute_CrossingZero_KeepsBothSides() + { + var scale = AxisScale.Compute(-30d, 70d, targetTicks: 5); + + Assert.True(scale.Min <= -30d); + Assert.True(scale.Max >= 70d); + Assert.Contains(0d, scale.Ticks); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~AxisScaleTests"` +Expected: BUILD FAILURE — `AxisScale` not defined. + +- [ ] **Step 3: Implement AxisScale** + +Create `src/FlexRender.Core/Charts/AxisScale.cs`: + +```csharp +using System; +using System.Collections.Generic; + +namespace FlexRender.Charts; + +/// +/// A computed numeric axis scale with "nice" rounded bounds and evenly spaced tick values. +/// Renderer-agnostic; produced from raw data min/max by . +/// +/// The lower bound of the axis (a nice rounded value). +/// The upper bound of the axis (a nice rounded value). +/// The spacing between adjacent ticks. +/// The ordered tick values from to inclusive. +public readonly record struct AxisScale(double Min, double Max, double Step, IReadOnlyList Ticks) +{ + /// + /// Computes a nice axis scale covering ... + /// All-positive data is anchored at zero (bar/area baseline); all-negative data is anchored + /// at zero above; data crossing zero keeps both sides and always includes a zero tick. + /// Identical or empty inputs collapse to a unit range so a chart can still draw. + /// + /// The smallest data value. + /// The largest data value. + /// The desired approximate number of tick intervals (default 5). + /// The computed . + public static AxisScale Compute(double dataMin, double dataMax, int targetTicks = 5) + { + if (targetTicks < 1) + targetTicks = 1; + + // Normalize degenerate inputs. + if (!double.IsFinite(dataMin) || !double.IsFinite(dataMax)) + { + dataMin = 0d; + dataMax = 1d; + } + + if (dataMin > dataMax) + (dataMin, dataMax) = (dataMax, dataMin); + + // Anchor at zero so bars/areas have a baseline. + if (dataMin > 0d) + dataMin = 0d; + if (dataMax < 0d) + dataMax = 0d; + + // Identical values (e.g. single point, all-equal): expand to a unit range around the value. + if (dataMin == dataMax) + { + if (dataMin == 0d) + { + dataMax = 1d; + } + else if (dataMin > 0d) + { + dataMin = 0d; + } + else + { + dataMax = 0d; + } + } + + var range = dataMax - dataMin; + var rawStep = range / targetTicks; + var step = NiceNumber(rawStep, round: true); + if (step <= 0d) + step = 1d; + + var niceMin = Math.Floor(dataMin / step) * step; + var niceMax = Math.Ceiling(dataMax / step) * step; + + var ticks = new List(); + // Use an explicit count to avoid floating-point accumulation drift. + var count = (int)Math.Round((niceMax - niceMin) / step); + for (var i = 0; i <= count; i++) + { + ticks.Add(niceMin + (i * step)); + } + + return new AxisScale(niceMin, niceMax, step, ticks); + } + + /// + /// Rounds a positive number to a "nice" value (1, 2, 5, or 10 times a power of ten), + /// the standard heuristic for readable axis ticks. + /// + /// The raw value to round (must be positive). + /// When true, rounds to the nearest nice value; otherwise rounds up. + /// The nice number. + private static double NiceNumber(double value, bool round) + { + if (value <= 0d) + return 1d; + + var exponent = Math.Floor(Math.Log10(value)); + var fraction = value / Math.Pow(10d, exponent); + + double niceFraction; + if (round) + { + niceFraction = fraction < 1.5d ? 1d + : fraction < 3d ? 2d + : fraction < 7d ? 5d + : 10d; + } + else + { + niceFraction = fraction <= 1d ? 1d + : fraction <= 2d ? 2d + : fraction <= 5d ? 5d + : 10d; + } + + return niceFraction * Math.Pow(10d, exponent); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~AxisScaleTests"` +Expected: PASS (4 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Charts/AxisScale.cs tests/FlexRender.Tests/Charts/AxisScaleTests.cs +git commit --no-gpg-sign -m "feat(charts): add nice-tick axis scale math" +``` + +--- + +## Task 4: AxisScale — edge cases (single point, identical, empty) + +Add the remaining edge-case coverage to the same test class so the math is bullet-proof. + +**Files:** +- Modify: `tests/FlexRender.Tests/Charts/AxisScaleTests.cs` + +- [ ] **Step 1: Add the failing tests** + +Append these methods inside the `AxisScaleTests` class (before its closing brace): + +```csharp + [Fact] + public void Compute_SinglePointPositive_AnchorsAtZeroWithRange() + { + var scale = AxisScale.Compute(42d, 42d, targetTicks: 5); + + Assert.Equal(0d, scale.Min); + Assert.True(scale.Max >= 42d); + Assert.True(scale.Step > 0d); + Assert.True(scale.Ticks.Count >= 2); + } + + [Fact] + public void Compute_AllZeroValues_ProducesUnitRange() + { + var scale = AxisScale.Compute(0d, 0d, targetTicks: 5); + + Assert.Equal(0d, scale.Min); + Assert.True(scale.Max > 0d); + Assert.True(scale.Step > 0d); + } + + [Fact] + public void Compute_SinglePointNegative_AnchorsMaxAtZero() + { + var scale = AxisScale.Compute(-7d, -7d, targetTicks: 5); + + Assert.True(scale.Min <= -7d); + Assert.Equal(0d, scale.Max); + } + + [Fact] + public void Compute_NonFiniteInputs_FallBackToUnitRange() + { + var scale = AxisScale.Compute(double.NaN, double.PositiveInfinity, targetTicks: 5); + + Assert.True(double.IsFinite(scale.Min)); + Assert.True(double.IsFinite(scale.Max)); + Assert.True(scale.Step > 0d); + Assert.True(scale.Max > scale.Min); + } + + [Fact] + public void Compute_TicksAreMonotonicAndEvenlySpaced() + { + var scale = AxisScale.Compute(0d, 95d, targetTicks: 5); + + for (var i = 1; i < scale.Ticks.Count; i++) + { + var delta = scale.Ticks[i] - scale.Ticks[i - 1]; + Assert.True(System.Math.Abs(delta - scale.Step) < 1e-9, $"Tick spacing {delta} != step {scale.Step}"); + } + } +``` + +- [ ] **Step 2: Run tests to verify they pass (implementation already handles these)** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~AxisScaleTests"` +Expected: PASS (9 cases total). If any edge case fails, fix `AxisScale.Compute` before committing. + +- [ ] **Step 3: Commit** + +```bash +git add tests/FlexRender.Tests/Charts/AxisScaleTests.cs +git commit --no-gpg-sign -m "test(charts): exhaustive axis-scale edge cases" +``` + +--- + +## Task 5: ChartEnums — ChartType, LegendPosition, PieLabelMode + +**Files:** +- Create: `src/FlexRender.Core/Charts/ChartEnums.cs` +- Test: `tests/FlexRender.Tests/Charts/ChartEnumsTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Charts/ChartEnumsTests.cs`: + +```csharp +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Sanity tests for the chart enums. +/// +public sealed class ChartEnumsTests +{ + [Theory] + [InlineData("Bar")] + [InlineData("Line")] + [InlineData("Area")] + [InlineData("Pie")] + [InlineData("Donut")] + public void ChartType_HasAllPhase2Members(string name) + { + Assert.True(System.Enum.TryParse(name, out _)); + } + + [Theory] + [InlineData("Top")] + [InlineData("Bottom")] + [InlineData("Left")] + [InlineData("Right")] + [InlineData("None")] + public void LegendPosition_HasAllMembers(string name) + { + Assert.True(System.Enum.TryParse(name, out _)); + } + + [Theory] + [InlineData("Percent")] + [InlineData("Value")] + [InlineData("None")] + public void PieLabelMode_HasAllMembers(string name) + { + Assert.True(System.Enum.TryParse(name, out _)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartEnumsTests"` +Expected: BUILD FAILURE — enums not defined. + +- [ ] **Step 3: Implement ChartEnums** + +Create `src/FlexRender.Core/Charts/ChartEnums.cs`: + +```csharp +namespace FlexRender.Charts; + +/// +/// The kind of chart to render. +/// +public enum ChartType +{ + /// Vertical (or horizontal) bar chart. + Bar, + + /// Line chart. + Line, + + /// Filled area chart. + Area, + + /// Pie chart. + Pie, + + /// Donut (ring) chart. + Donut +} + +/// +/// Where the legend is placed relative to the plot area. +/// +public enum LegendPosition +{ + /// Above the plot area. + Top, + + /// Below the plot area. + Bottom, + + /// Left of the plot area. + Left, + + /// Right of the plot area. + Right, + + /// No legend. + None +} + +/// +/// How pie/donut slice labels are rendered. +/// +public enum PieLabelMode +{ + /// Show each slice's percentage of the total. + Percent, + + /// Show each slice's raw value. + Value, + + /// Show no slice labels. + None +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartEnumsTests"` +Expected: PASS (13 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Charts/ChartEnums.cs tests/FlexRender.Tests/Charts/ChartEnumsTests.cs +git commit --no-gpg-sign -m "feat(charts): add ChartType, LegendPosition, PieLabelMode enums" +``` + +--- + +## Task 6: ChartPalettes — named series-color ramps + +**Files:** +- Create: `src/FlexRender.Core/Charts/ChartPalette.cs` +- Create: `src/FlexRender.Core/Charts/ChartPalettes.cs` +- Test: `tests/FlexRender.Tests/Charts/ChartPalettesTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Charts/ChartPalettesTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for named palette resolution and color cycling. +/// +public sealed class ChartPalettesTests +{ + [Theory] + [InlineData("default")] + [InlineData("ocean")] + [InlineData("sunset")] + [InlineData("forest")] + [InlineData("mono")] + [InlineData("vivid")] + public void Resolve_KnownName_ReturnsNonEmptyPalette(string name) + { + var palette = ChartPalettes.Resolve(name); + Assert.NotNull(palette); + Assert.NotEmpty(palette!.Colors); + } + + [Fact] + public void Resolve_UnknownName_ReturnsNull() + { + Assert.Null(ChartPalettes.Resolve("does-not-exist")); + } + + [Fact] + public void Resolve_IsCaseInsensitive() + { + Assert.NotNull(ChartPalettes.Resolve("OCEAN")); + } + + [Fact] + public void ColorAt_CyclesWhenIndexExceedsCount() + { + var palette = new ChartPalette(new[] { "#111111", "#222222" }); + Assert.Equal("#111111", palette.ColorAt(0)); + Assert.Equal("#222222", palette.ColorAt(1)); + Assert.Equal("#111111", palette.ColorAt(2)); + } + + [Fact] + public void Default_IsNonEmpty() + { + Assert.NotEmpty(ChartPalettes.Default.Colors); + } + + [Fact] + public void Constructor_NullColors_Throws() + { + Assert.Throws(() => new ChartPalette(null!)); + } + + [Fact] + public void Constructor_EmptyColors_Throws() + { + Assert.Throws(() => new ChartPalette(new List())); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartPalettesTests"` +Expected: BUILD FAILURE — `ChartPalette`/`ChartPalettes` not defined. + +- [ ] **Step 3: Implement ChartPalette and ChartPalettes** + +Create `src/FlexRender.Core/Charts/ChartPalette.cs`: + +```csharp +using System; +using System.Collections.Generic; + +namespace FlexRender.Charts; + +/// +/// An ordered ramp of hex series colors. Colors cycle when there are more series than colors. +/// +public sealed class ChartPalette +{ + /// + /// Initializes a new instance of the class. + /// + /// The ordered hex color strings. Must be non-null and non-empty. + /// Thrown when is null. + /// Thrown when is empty. + public ChartPalette(IReadOnlyList colors) + { + ArgumentNullException.ThrowIfNull(colors); + if (colors.Count == 0) + throw new ArgumentException("A palette must contain at least one color.", nameof(colors)); + Colors = colors; + } + + /// + /// Gets the ordered hex color strings. + /// + public IReadOnlyList Colors { get; } + + /// + /// Returns the color for a series index, cycling through when the + /// index exceeds the palette size. + /// + /// The zero-based series index (must be non-negative). + /// The hex color string for the series. + public string ColorAt(int index) + { + var i = index % Colors.Count; + if (i < 0) + i += Colors.Count; + return Colors[i]; + } +} +``` + +Create `src/FlexRender.Core/Charts/ChartPalettes.cs`: + +```csharp +using System; +using System.Collections.Generic; + +namespace FlexRender.Charts; + +/// +/// Static registry of named series-color palettes (AOT-safe, no config files). +/// +public static class ChartPalettes +{ + /// The default palette used when none is specified. + public static ChartPalette Default { get; } = new(new[] + { + "#4A90D9", "#E2725B", "#7FB069", "#F4C430", "#9B6DD6", "#54B8B1", "#E0719C", "#A0A0A0" + }); + + private static readonly ChartPalette Ocean = new(new[] + { + "#264653", "#2A9D8F", "#48BFE3", "#56CFE1", "#64DFDF", "#80FFDB" + }); + + private static readonly ChartPalette Sunset = new(new[] + { + "#003049", "#D62828", "#F77F00", "#FCBF49", "#EAE2B7" + }); + + private static readonly ChartPalette Forest = new(new[] + { + "#1B4332", "#2D6A4F", "#40916C", "#52B788", "#74C69D", "#95D5B2" + }); + + private static readonly ChartPalette Mono = new(new[] + { + "#222222", "#444444", "#666666", "#888888", "#AAAAAA", "#CCCCCC" + }); + + private static readonly ChartPalette Vivid = new(new[] + { + "#E63946", "#F1A208", "#2A9D8F", "#3A86FF", "#8338EC", "#FF006E" + }); + + private static readonly Dictionary Registry = new(StringComparer.OrdinalIgnoreCase) + { + ["default"] = Default, + ["ocean"] = Ocean, + ["sunset"] = Sunset, + ["forest"] = Forest, + ["mono"] = Mono, + ["vivid"] = Vivid + }; + + /// + /// Resolves a named palette case-insensitively. + /// + /// The palette name (e.g. "ocean"). + /// The matching , or null when the name is unknown. + public static ChartPalette? Resolve(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + return Registry.TryGetValue(name, out var palette) ? palette : null; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartPalettesTests"` +Expected: PASS (12 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Charts/ChartPalette.cs src/FlexRender.Core/Charts/ChartPalettes.cs tests/FlexRender.Tests/Charts/ChartPalettesTests.cs +git commit --no-gpg-sign -m "feat(charts): add named series palettes" +``` + +--- + +## Task 7: ChartThemes — light/dark/minimal theme data + +**Files:** +- Create: `src/FlexRender.Core/Charts/ChartTheme.cs` +- Create: `src/FlexRender.Core/Charts/ChartThemes.cs` +- Test: `tests/FlexRender.Tests/Charts/ChartThemesTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Charts/ChartThemesTests.cs`: + +```csharp +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for named chart theme resolution. +/// +public sealed class ChartThemesTests +{ + [Theory] + [InlineData("light")] + [InlineData("dark")] + [InlineData("minimal")] + public void Resolve_KnownName_ReturnsTheme(string name) + { + var theme = ChartThemes.Resolve(name); + Assert.NotNull(theme); + } + + [Fact] + public void Resolve_UnknownName_ReturnsNull() + { + Assert.Null(ChartThemes.Resolve("neon")); + } + + [Fact] + public void Resolve_IsCaseInsensitive() + { + Assert.NotNull(ChartThemes.Resolve("DARK")); + } + + [Fact] + public void Default_IsLight() + { + Assert.Same(ChartThemes.Resolve("light"), ChartThemes.Default); + } + + [Fact] + public void LightTheme_HasNonEmptyColors() + { + var theme = ChartThemes.Default; + Assert.False(string.IsNullOrEmpty(theme.BackgroundColor)); + Assert.False(string.IsNullOrEmpty(theme.GridColor)); + Assert.False(string.IsNullOrEmpty(theme.AxisColor)); + Assert.False(string.IsNullOrEmpty(theme.LabelColor)); + Assert.True(theme.LabelSize > 0f); + Assert.True(theme.TitleSize > 0f); + Assert.True(theme.LineWidth > 0f); + Assert.True(theme.BarCornerRadius >= 0f); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartThemesTests"` +Expected: BUILD FAILURE — `ChartTheme`/`ChartThemes` not defined. + +- [ ] **Step 3: Implement ChartTheme and ChartThemes** + +Create `src/FlexRender.Core/Charts/ChartTheme.cs`: + +```csharp +namespace FlexRender.Charts; + +/// +/// Visual styling preset for a chart: colors, label/title sizes, line widths and bar rounding. +/// Renderer-agnostic, immutable static data. +/// +/// The chart background fill (hex). Empty means transparent. +/// The grid-line color (hex). +/// The axis-line color (hex). +/// The axis/legend/slice label text color (hex). +/// The title text color (hex). +/// The label font size in pixels. +/// The title font size in pixels. +/// The series line width in pixels (line/area charts). +/// The corner radius applied to bars in pixels. +public sealed record ChartTheme( + string BackgroundColor, + string GridColor, + string AxisColor, + string LabelColor, + string TitleColor, + float LabelSize, + float TitleSize, + float LineWidth, + float BarCornerRadius); +``` + +Create `src/FlexRender.Core/Charts/ChartThemes.cs`: + +```csharp +using System; +using System.Collections.Generic; + +namespace FlexRender.Charts; + +/// +/// Static registry of named chart themes (AOT-safe, no config files). +/// +public static class ChartThemes +{ + /// The light theme, used as the default. + public static ChartTheme Default { get; } = new( + BackgroundColor: "#FFFFFF", + GridColor: "#E6E6E6", + AxisColor: "#999999", + LabelColor: "#555555", + TitleColor: "#222222", + LabelSize: 12f, + TitleSize: 16f, + LineWidth: 2.5f, + BarCornerRadius: 3f); + + private static readonly ChartTheme Dark = new( + BackgroundColor: "#1E1E1E", + GridColor: "#3A3A3A", + AxisColor: "#777777", + LabelColor: "#CCCCCC", + TitleColor: "#F0F0F0", + LabelSize: 12f, + TitleSize: 16f, + LineWidth: 2.5f, + BarCornerRadius: 3f); + + private static readonly ChartTheme Minimal = new( + BackgroundColor: "#FFFFFF", + GridColor: "#F0F0F0", + AxisColor: "#CCCCCC", + LabelColor: "#666666", + TitleColor: "#333333", + LabelSize: 11f, + TitleSize: 15f, + LineWidth: 2f, + BarCornerRadius: 0f); + + private static readonly Dictionary Registry = new(StringComparer.OrdinalIgnoreCase) + { + ["light"] = Default, + ["dark"] = Dark, + ["minimal"] = Minimal + }; + + /// + /// Resolves a named theme case-insensitively. + /// + /// The theme name (e.g. "dark"). + /// The matching , or null when the name is unknown. + public static ChartTheme? Resolve(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + return Registry.TryGetValue(name, out var theme) ? theme : null; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartThemesTests"` +Expected: PASS (8 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Charts/ChartTheme.cs src/FlexRender.Core/Charts/ChartThemes.cs tests/FlexRender.Tests/Charts/ChartThemesTests.cs +git commit --no-gpg-sign -m "feat(charts): add light/dark/minimal themes" +``` + +--- + +## Task 8: ChartSeries — resolved-series DTO + +The series carries a YAML-time data form (inline array OR expression string) plus the resolved numeric `double[]` filled during expression resolution. + +**Files:** +- Create: `src/FlexRender.Core/Parsing/Ast/ChartSeries.cs` +- Test: `tests/FlexRender.Tests/Parsing/Ast/ChartSeriesTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Ast/ChartSeriesTests.cs`: + +```csharp +using System; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the DTO. +/// +public sealed class ChartSeriesTests +{ + [Fact] + public void InlineData_StoresLabelAndValues() + { + var series = ChartSeries.FromInline("2024", new[] { 12d, 30d, 22d, 48d }); + + Assert.Equal("2024", series.Label); + Assert.Null(series.DataExpression); + Assert.Equal(new[] { 12d, 30d, 22d, 48d }, series.Data); + } + + [Fact] + public void Expression_StoresExpressionAndEmptyData() + { + var series = ChartSeries.FromExpression("Sales", "{{ sales }}"); + + Assert.Equal("Sales", series.Label); + Assert.Equal("{{ sales }}", series.DataExpression); + Assert.Empty(series.Data); + } + + [Fact] + public void WithData_ReplacesDataKeepingLabel() + { + var series = ChartSeries.FromExpression("Sales", "{{ sales }}"); + var resolved = series.WithData(new[] { 1d, 2d, 3d }); + + Assert.Equal("Sales", resolved.Label); + Assert.Equal("{{ sales }}", resolved.DataExpression); + Assert.Equal(new[] { 1d, 2d, 3d }, resolved.Data); + } + + [Fact] + public void FromInline_NullData_Throws() + { + Assert.Throws(() => ChartSeries.FromInline("x", null!)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSeriesTests"` +Expected: BUILD FAILURE — `ChartSeries` not defined. + +- [ ] **Step 3: Implement ChartSeries** + +Create `src/FlexRender.Core/Parsing/Ast/ChartSeries.cs`: + +```csharp +using System; +using System.Collections.Generic; + +namespace FlexRender.Parsing.Ast; + +/// +/// A single chart data series: a label plus its numeric values. The values may come from an +/// inline YAML array (resolved at parse time) or from a template expression (resolved against +/// the data context during ). +/// +public sealed class ChartSeries +{ + private static readonly IReadOnlyList Empty = Array.Empty(); + + private ChartSeries(string? label, string? dataExpression, IReadOnlyList data) + { + Label = label; + DataExpression = dataExpression; + Data = data; + } + + /// Gets the optional series label shown in the legend. + public string? Label { get; } + + /// + /// Gets the raw template expression (e.g. "{{ sales }}") when the data is data-bound; + /// null when the data was supplied inline. + /// + public string? DataExpression { get; } + + /// Gets the resolved numeric values. Empty until a bound expression is resolved. + public IReadOnlyList Data { get; } + + /// + /// Creates a series with inline numeric data. + /// + /// The optional legend label. + /// The numeric values. + /// A new . + /// Thrown when is null. + public static ChartSeries FromInline(string? label, IReadOnlyList data) + { + ArgumentNullException.ThrowIfNull(data); + return new ChartSeries(label, dataExpression: null, data); + } + + /// + /// Creates a data-bound series whose values come from a template expression. + /// + /// The optional legend label. + /// The raw expression string (e.g. "{{ sales }}"). + /// A new with empty data until resolved. + public static ChartSeries FromExpression(string? label, string dataExpression) + { + ArgumentNullException.ThrowIfNull(dataExpression); + return new ChartSeries(label, dataExpression, Empty); + } + + /// + /// Returns a copy of this series with its replaced, preserving the label + /// and expression. Used when a bound expression has been resolved to concrete values. + /// + /// The resolved numeric values. + /// A new with the new data. + /// Thrown when is null. + public ChartSeries WithData(IReadOnlyList data) + { + ArgumentNullException.ThrowIfNull(data); + return new ChartSeries(Label, DataExpression, data); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSeriesTests"` +Expected: PASS (4 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Ast/ChartSeries.cs tests/FlexRender.Tests/Parsing/Ast/ChartSeriesTests.cs +git commit --no-gpg-sign -m "feat(ast): add ChartSeries DTO" +``` + +--- + +## Task 9: ChartElement AST class (structure + Type + clone) + +The data-binding override of `ResolveExpressions` comes later (Task 16). This task lands the leaf-box element with all chart properties. + +**Files:** +- Create: `src/FlexRender.Core/Parsing/Ast/ChartElement.cs` +- Test: `tests/FlexRender.Tests/Parsing/Ast/ChartElementTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Ast/ChartElementTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the AST class. +/// +public sealed class ChartElementTests +{ + private static ChartElement MakeChart() + { + var series = new List + { + ChartSeries.FromInline("2024", new[] { 12d, 30d, 22d, 48d }) + }; + return new ChartElement(ChartType.Bar, series) + { + Categories = new[] { "Q1", "Q2", "Q3", "Q4" }, + Width = "600", + Height = "300", + Legend = LegendPosition.Bottom, + Title = "Revenue" + }; + } + + [Fact] + public void Type_IsChart() + { + Assert.Equal(ElementType.Chart, MakeChart().Type); + } + + [Fact] + public void ChartType_AndSeries_AreExposed() + { + var chart = MakeChart(); + Assert.Equal(ChartType.Bar, chart.ChartType); + Assert.Single(chart.Series); + Assert.Equal(4, chart.Categories.Count); + } + + [Fact] + public void Defaults_AreSensible() + { + var chart = new ChartElement(ChartType.Line, new List()); + Assert.Empty(chart.Series); + Assert.Empty(chart.Categories); + Assert.Null(chart.Title); + Assert.False(chart.Horizontal); + Assert.False(chart.Stacked); + Assert.False(chart.Smooth); + Assert.False(chart.ShowPoints); + Assert.Equal(PieLabelMode.Percent, chart.PieLabels); + } + + [Fact] + public void CloneWithSubstitution_PreservesChartState() + { + var clone = (ChartElement)MakeChart().CloneWithSubstitution(s => s); + + Assert.Equal(ChartType.Bar, clone.ChartType); + Assert.Single(clone.Series); + Assert.Equal("Revenue", clone.Title); + Assert.Equal("600", clone.Width.Value); + Assert.Equal(LegendPosition.Bottom, clone.Legend); + } + + [Fact] + public void Constructor_NullSeries_Throws() + { + Assert.Throws(() => new ChartElement(ChartType.Bar, null!)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementTests"` +Expected: BUILD FAILURE — `ChartElement` not defined. + +- [ ] **Step 3: Implement ChartElement (no data-binding override yet)** + +Create `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`: + +```csharp +using System; +using System.Collections.Generic; +using FlexRender.Charts; + +namespace FlexRender.Parsing.Ast; + +/// +/// A chart element. Participates in flex layout as a leaf box with explicit width/height and is +/// drawn by the renderer into that box: grid, axes, series geometry, legend, title. The visual +/// styling comes entirely from the resolved and ; +/// the template only supplies data and optional theme/palette words. +/// +public sealed class ChartElement : TemplateElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The chart type. + /// The data series (may be empty; an empty chart renders a "no data" placeholder). + /// Thrown when is null. + public ChartElement(ChartType chartType, IReadOnlyList series) + { + ArgumentNullException.ThrowIfNull(series); + ChartType = chartType; + Series = series; + } + + /// + public override ElementType Type => ElementType.Chart; + + /// Gets the chart type. + public ChartType ChartType { get; private set; } + + /// Gets the data series (resolved during expression resolution). + public IReadOnlyList Series { get; private set; } + + /// Gets or sets the category labels (x-axis categories or pie slice labels). + public IReadOnlyList Categories { get; set; } = Array.Empty(); + + /// Gets or sets the resolved palette name or explicit color list. Null uses the theme default palette. + public ChartPalette? Palette { get; set; } + + /// Gets or sets the resolved theme. Null falls back to the template/canvas theme then light. + public ChartTheme? Theme { get; set; } + + /// Gets or sets the legend position. + public LegendPosition Legend { get; set; } = LegendPosition.Bottom; + + /// Gets or sets the optional chart title. + public string? Title { get; set; } + + /// Gets or sets whether bars are drawn horizontally (bar charts only). + public bool Horizontal { get; set; } + + /// Gets or sets whether bars/areas are stacked (bar charts only in this phase). + public bool Stacked { get; set; } + + /// Gets or sets whether line/area series use smoothed curves. + public bool Smooth { get; set; } + + /// Gets or sets whether line/area series show point markers. + public bool ShowPoints { get; set; } + + /// Gets or sets how pie/donut slice labels are rendered. + public PieLabelMode PieLabels { get; set; } = PieLabelMode.Percent; + + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new ChartElement(ChartType, Series) + { + Categories = Categories, + Palette = Palette, + Theme = Theme, + Legend = Legend, + Title = Title, + Horizontal = Horizontal, + Stacked = Stacked, + Smooth = Smooth, + ShowPoints = ShowPoints, + PieLabels = PieLabels + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + + /// + /// Replaces the series collection. Used by expression resolution to install resolved data. + /// + /// The resolved series. + /// Thrown when is null. + internal void SetSeries(IReadOnlyList series) + { + ArgumentNullException.ThrowIfNull(series); + Series = series; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementTests"` +Expected: PASS (5 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Ast/ChartElement.cs tests/FlexRender.Tests/Parsing/Ast/ChartElementTests.cs +git commit --no-gpg-sign -m "feat(ast): add ChartElement" +``` + +--- + +## Task 10: Layout — chart leaf box + +Add `ChartElement` to the existing shape-intrinsic and shape-layout switch arms (same treatment as rect/draw). + +**Files:** +- Modify: `src/FlexRender.Core/Layout/IntrinsicMeasurer.cs:64` (the shape arm group) +- Modify: `src/FlexRender.Core/Layout/LayoutEngine.cs:219` (the shape arm group) +- Test: `tests/FlexRender.Tests/Layout/ChartLayoutTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Layout/ChartLayoutTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Configuration; +using FlexRender.Layout; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Layout; + +/// +/// Layout tests for the chart leaf element. +/// +public sealed class ChartLayoutTests +{ + [Fact] + public void Chart_WithExplicitSize_ProducesThatSize() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromInline("a", new[] { 1d, 2d, 3d }) + }) + { + Width = "600", + Height = "300" + }; + + var template = new Template + { + Canvas = new CanvasSettings { Width = 800, Fixed = FixedDimension.Width } + }; + template.AddElement(chart); + + var engine = new LayoutEngine(new ResourceLimits()); + var root = engine.ComputeLayout(template); + var node = root.Children[0]; + + Assert.Equal(600f, node.Width); + Assert.Equal(300f, node.Height); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLayoutTests"` +Expected: FAIL — chart is not in the layout switch, so size is not honoured (node width/height differ). + +- [ ] **Step 3: Add ChartElement to both switch arms** + +In `src/FlexRender.Core/Layout/IntrinsicMeasurer.cs`, in the `MeasureIntrinsic` switch (currently lines 60-64), add after the `DrawElement draw => MeasureShapeIntrinsic(draw),` arm: + +```csharp + ChartElement chart => MeasureShapeIntrinsic(chart), +``` + +In `src/FlexRender.Core/Layout/LayoutEngine.cs`, in the layout dispatch switch (currently lines 215-219), add after the `DrawElement draw => LayoutShapeElement(draw, context),` arm: + +```csharp + ChartElement chart => LayoutShapeElement(chart, context), +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLayoutTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Layout/IntrinsicMeasurer.cs src/FlexRender.Core/Layout/LayoutEngine.cs tests/FlexRender.Tests/Layout/ChartLayoutTests.cs +git commit --no-gpg-sign -m "feat(layout): treat chart as a leaf box" +``` + +--- + +## Task 11: ChartParsers — parse inline series + basic props + +Parse the chart element; series data may be an inline array or an expression. This task handles parsing into the AST (data-binding resolution is Task 16; for inline arrays the data is already concrete here). + +**Files:** +- Create: `src/FlexRender.Yaml/Parsing/ChartParsers.cs` +- Modify: `src/FlexRender.Yaml/Parsing/TemplateParser.cs:71` (register `chart`) +- Test: `tests/FlexRender.Tests/Parsing/ChartParsersTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/ChartParsersTests.cs`: + +```csharp +using FlexRender.Charts; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for parsing the chart element from YAML. +/// +public sealed class ChartParsersTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_BarChartWithInlineSeries_ProducesChartElement() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: bar + width: 600 + height: 300 + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: [12, 30, 22, 48] + palette: ocean + legend: bottom + title: Revenue + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal(ChartType.Bar, chart.ChartType); + Assert.Equal(new[] { "Q1", "Q2", "Q3", "Q4" }, chart.Categories); + Assert.Single(chart.Series); + Assert.Equal("2024", chart.Series[0].Label); + Assert.Equal(new[] { 12d, 30d, 22d, 48d }, chart.Series[0].Data); + Assert.NotNull(chart.Palette); + Assert.Equal(LegendPosition.Bottom, chart.Legend); + Assert.Equal("Revenue", chart.Title); + } + + [Fact] + public void Parse_SeriesWithExpression_StoresExpressionNotData() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: line + width: 600 + height: 300 + series: + - label: Sales + data: "{{ sales }}" + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal("{{ sales }}", chart.Series[0].DataExpression); + Assert.Empty(chart.Series[0].Data); + } + + [Fact] + public void Parse_ExplicitColorListPalette_IsAccepted() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: pie + width: 400 + height: 400 + categories: [A, B, C] + series: + - data: [10, 20, 30] + palette: ["#264653", "#2a9d8f", "#e9c46a"] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.NotNull(chart.Palette); + Assert.Equal("#264653", chart.Palette!.ColorAt(0)); + } + + [Fact] + public void Parse_BarTypeSpecificProps_AreApplied() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: bar + width: 600 + height: 300 + horizontal: true + stacked: true + series: + - data: [1, 2, 3] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.True(chart.Horizontal); + Assert.True(chart.Stacked); + } + + [Fact] + public void Parse_UnknownChartType_Throws() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: spider + width: 600 + height: 300 + series: + - data: [1, 2] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("chart-type", ex.Message); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartParsersTests"` +Expected: BUILD FAILURE — `ChartParsers` not defined and `chart` not registered (note: the unknown-property validation for `chart` is added in Task 13; for now `chart-type`, `series` etc. must parse). + +- [ ] **Step 3: Implement ChartParsers** + +Create `src/FlexRender.Yaml/Parsing/ChartParsers.cs`: + +```csharp +using System.Collections.Generic; +using System.Globalization; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using YamlDotNet.RepresentationModel; +using static FlexRender.Parsing.YamlPropertyHelpers; + +namespace FlexRender.Parsing; + +/// +/// Provides static helpers for parsing the chart element from YAML. +/// +public static class ChartParsers +{ + /// + /// Parses a chart element. + /// + /// The YAML node containing the chart definition. + /// The maximum number of series allowed. + /// The maximum number of data points per series. + /// The parsed . + /// Thrown on an unknown chart-type, malformed series, or exceeded limits. + internal static TemplateElement ParseChartElement(YamlMappingNode node, int maxSeries, int maxDataPoints) + { + ArgumentNullException.ThrowIfNull(node); + + var chartType = ParseChartType(node); + var series = ParseSeries(node, maxSeries, maxDataPoints); + + var chart = new ChartElement(chartType, series) + { + Categories = ParseCategories(node), + Palette = ParsePalette(node), + Theme = ParseTheme(node), + Legend = ParseLegend(node), + Title = GetStringValue(node, "title"), + Horizontal = GetBoolValue(node, "horizontal", false), + Stacked = GetBoolValue(node, "stacked", false), + Smooth = GetBoolValue(node, "smooth", false), + ShowPoints = GetBoolValue(node, "points", false), + PieLabels = ParsePieLabels(node), + Background = GetStringValue(node, "background")!, + Rotate = GetExprStringValue(node, "rotate", "none"), + Padding = GetExprStringValue(node, "padding", "0"), + Margin = GetExprStringValue(node, "margin", "0") + }; + + ElementParsers.ApplyFlexItemProperties(node, chart); + return chart; + } + + private static ChartType ParseChartType(YamlMappingNode node) + { + var raw = GetStringValue(node, "chart-type", "bar"); + if (!System.Enum.TryParse(raw, ignoreCase: true, out var chartType)) + { + throw new TemplateParseException( + $"Unknown chart-type '{raw}'. Valid values: bar, line, area, pie, donut."); + } + return chartType; + } + + private static LegendPosition ParseLegend(YamlMappingNode node) + { + var raw = GetStringValue(node, "legend", "bottom"); + if (!System.Enum.TryParse(raw, ignoreCase: true, out var legend)) + { + throw new TemplateParseException( + $"Unknown legend position '{raw}'. Valid values: top, bottom, left, right, none."); + } + return legend; + } + + private static PieLabelMode ParsePieLabels(YamlMappingNode node) + { + var raw = GetStringValue(node, "labels", "percent"); + if (!System.Enum.TryParse(raw, ignoreCase: true, out var mode)) + { + throw new TemplateParseException( + $"Unknown labels mode '{raw}'. Valid values: percent, value, none."); + } + return mode; + } + + private static IReadOnlyList ParseCategories(YamlMappingNode node) + { + var categories = new List(); + if (TryGetSequence(node, "categories", out var seq)) + { + foreach (var item in seq.Children) + { + if (item is YamlScalarNode scalar && scalar.Value is not null) + categories.Add(scalar.Value); + } + } + return categories; + } + + private static ChartPalette? ParsePalette(YamlMappingNode node) + { + // Explicit color list form. + if (TryGetSequence(node, "palette", out var seq)) + { + var colors = new List(); + foreach (var item in seq.Children) + { + if (item is YamlScalarNode scalar && !string.IsNullOrWhiteSpace(scalar.Value)) + colors.Add(scalar.Value.Trim()); + } + if (colors.Count == 0) + throw new TemplateParseException("A 'palette' color list must contain at least one color."); + return new ChartPalette(colors); + } + + // Named palette form. + var name = GetStringValue(node, "palette"); + if (string.IsNullOrWhiteSpace(name)) + return null; + + var palette = ChartPalettes.Resolve(name); + if (palette is null) + throw new TemplateParseException( + $"Unknown palette '{name}'. Valid names: default, ocean, sunset, forest, mono, vivid (or an explicit color list)."); + return palette; + } + + private static ChartTheme? ParseTheme(YamlMappingNode node) + { + var name = GetStringValue(node, "theme"); + if (string.IsNullOrWhiteSpace(name)) + return null; + + var theme = ChartThemes.Resolve(name); + if (theme is null) + throw new TemplateParseException( + $"Unknown chart theme '{name}'. Valid names: light, dark, minimal."); + return theme; + } + + private static IReadOnlyList ParseSeries(YamlMappingNode node, int maxSeries, int maxDataPoints) + { + var result = new List(); + + if (!TryGetSequence(node, "series", out var seriesSeq)) + return result; + + if (seriesSeq.Children.Count > maxSeries) + { + throw new TemplateParseException( + $"Chart has {seriesSeq.Children.Count} series, which exceeds the maximum of {maxSeries}."); + } + + foreach (var item in seriesSeq.Children) + { + if (item is not YamlMappingNode seriesNode) + throw new TemplateParseException("Each entry in 'series' must be a mapping with a 'data' field."); + + result.Add(ParseOneSeries(seriesNode, maxDataPoints)); + } + + return result; + } + + private static ChartSeries ParseOneSeries(YamlMappingNode seriesNode, int maxDataPoints) + { + var label = GetStringValue(seriesNode, "label"); + + // Inline array form. + if (TryGetSequence(seriesNode, "data", out var dataSeq)) + { + if (dataSeq.Children.Count > maxDataPoints) + { + throw new TemplateParseException( + $"Series '{label ?? "(unlabeled)"}' has {dataSeq.Children.Count} data points, which exceeds the maximum of {maxDataPoints}."); + } + + var values = new List(dataSeq.Children.Count); + foreach (var v in dataSeq.Children) + { + if (v is not YamlScalarNode scalar + || !double.TryParse(scalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) + || !double.IsFinite(d)) + { + throw new TemplateParseException( + $"Series '{label ?? "(unlabeled)"}' contains a non-numeric data value '{(v as YamlScalarNode)?.Value}'."); + } + values.Add(d); + } + return ChartSeries.FromInline(label, values); + } + + // Expression form (scalar string containing {{ }}). + var expr = GetStringValue(seriesNode, "data"); + if (!string.IsNullOrWhiteSpace(expr)) + return ChartSeries.FromExpression(label, expr); + + // No data at all → empty inline series (renders as "no data" if all series empty). + return ChartSeries.FromInline(label, System.Array.Empty()); + } +} +``` + +- [ ] **Step 4: Register `chart` in TemplateParser** + +In `src/FlexRender.Yaml/Parsing/TemplateParser.cs`, in the `_elementParsers` dictionary initializer (the block ending at line 71 with the `draw` entry), add after the `["draw"] = ...` entry: + +```csharp + ["chart"] = node => ChartParsers.ParseChartElement(node, _limits.MaxSeriesPerChart, _limits.MaxDataPointsPerSeries) +``` + +(Ensure the preceding `draw` line ends with a comma.) + +- [ ] **Step 5: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartParsersTests"` +Expected: PASS (5 cases). + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Yaml/Parsing/ChartParsers.cs src/FlexRender.Yaml/Parsing/TemplateParser.cs tests/FlexRender.Tests/Parsing/ChartParsersTests.cs +git commit --no-gpg-sign -m "feat(parser): parse chart element with inline series, palettes, themes" +``` + +--- + +## Task 12: KnownProperties — chart property set + typo suggestions + +**Files:** +- Modify: `src/FlexRender.Yaml/Parsing/KnownProperties.cs` (add `Chart` set + registry entry) +- Test: `tests/FlexRender.Tests/Parsing/ChartKnownPropertiesTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/ChartKnownPropertiesTests.cs`: + +```csharp +using FlexRender.Parsing; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for chart property validation and typo suggestions. +/// +public sealed class ChartKnownPropertiesTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_TypoInChartType_SuggestsCorrection() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-typ: bar + width: 600 + height: 300 + series: + - data: [1, 2] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("chart-type", ex.Message); + } + + [Fact] + public void Parse_UnknownChartProperty_Throws() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: bar + width: 600 + height: 300 + bogus: 1 + series: + - data: [1, 2] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("bogus", ex.Message); + } + + [Fact] + public void Parse_AllKnownChartProps_DoesNotThrow() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: bar + width: 600 + height: 300 + categories: [A, B] + series: + - label: x + data: [1, 2] + palette: ocean + theme: dark + legend: top + title: T + horizontal: true + stacked: true + smooth: false + points: false + labels: percent + """; + + var template = _parser.Parse(yaml); + Assert.NotNull(template); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartKnownPropertiesTests"` +Expected: FAIL — `chart` is not in the `Registry`, so validation is skipped and the typo/unknown-property tests do not throw. + +- [ ] **Step 3: Add the Chart property set + registry entry** + +In `src/FlexRender.Yaml/Parsing/KnownProperties.cs`, after the `Draw` set (around line 194) add: + +```csharp + /// + /// Known properties for the 'chart' element type. + /// + internal static readonly HashSet Chart = BuildSet(FlexItemProperties, + [ + "chart-type", "categories", "series", "palette", "theme", "legend", "title", + "horizontal", "stacked", "smooth", "points", "labels", + "background", "rotate", "padding", "margin" + ]); +``` + +In the `Registry` dictionary initializer (around lines 199-217), add after the `["draw"] = Draw` entry (add a comma to that line): + +```csharp + ["chart"] = Chart +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartKnownPropertiesTests"` +Expected: PASS (3 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Yaml/Parsing/KnownProperties.cs tests/FlexRender.Tests/Parsing/ChartKnownPropertiesTests.cs +git commit --no-gpg-sign -m "feat(parser): register chart known properties with typo suggestions" +``` + +--- + +## Task 13: ChartElement — series-data expression → array binding (HIGH RISK) + +Override `ResolveExpressions` so that data-bound series resolve their `{{ expr }}` to a numeric `double[]` against the data context. Inline series pass through unchanged. Non-numeric values raise a clear template error. + +**Files:** +- Modify: `src/FlexRender.Core/Parsing/Ast/ChartElement.cs` +- Test: `tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.TemplateEngine; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for resolving data-bound chart series against the data context. +/// +public sealed class ChartElementDataBindingTests +{ + private static string PassthroughResolver(string raw, ObjectValue data) => raw; + + [Fact] + public void ResolveExpressions_BoundSeries_ResolvesArrayFromContext() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromExpression("Sales", "{{ sales }}") + }); + + var data = new ObjectValue + { + ["sales"] = new ArrayValue(new TemplateValue[] + { + new NumberValue(12m), new NumberValue(30m), new NumberValue(22m) + }) + }; + + chart.ResolveExpressions(PassthroughResolver, data); + + Assert.Equal(new[] { 12d, 30d, 22d }, chart.Series[0].Data); + } + + [Fact] + public void ResolveExpressions_InlineSeries_Unchanged() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromInline("a", new[] { 1d, 2d, 3d }) + }); + + chart.ResolveExpressions(PassthroughResolver, new ObjectValue()); + + Assert.Equal(new[] { 1d, 2d, 3d }, chart.Series[0].Data); + } + + [Fact] + public void ResolveExpressions_MissingPath_ResolvesToEmptyData() + { + var chart = new ChartElement(ChartType.Line, new List + { + ChartSeries.FromExpression("x", "{{ nothere }}") + }); + + chart.ResolveExpressions(PassthroughResolver, new ObjectValue()); + + Assert.Empty(chart.Series[0].Data); + } + + [Fact] + public void ResolveExpressions_NonNumericArrayItem_Throws() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromExpression("Sales", "{{ sales }}") + }); + + var data = new ObjectValue + { + ["sales"] = new ArrayValue(new TemplateValue[] + { + new NumberValue(12m), new StringValue("oops") + }) + }; + + var ex = Assert.Throws(() => chart.ResolveExpressions(PassthroughResolver, data)); + Assert.Contains("Sales", ex.Message); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementDataBindingTests"` +Expected: FAIL — base `ResolveExpressions` does not resolve series data (bound series remain empty). + +- [ ] **Step 3: Add the data-binding override** + +In `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`, add these usings at the top (after the existing ones): + +```csharp +using FlexRender.TemplateEngine; +``` + +Then add this override inside the class (after `CloneWithSubstitution`): + +```csharp + /// + public override void ResolveExpressions(Func resolver, ObjectValue data) + { + base.ResolveExpressions(resolver, data); + ArgumentNullException.ThrowIfNull(data); + + var anyBound = false; + foreach (var s in Series) + { + if (s.DataExpression is not null) + { + anyBound = true; + break; + } + } + + if (!anyBound) + return; + + var context = new TemplateContext(data); + var resolved = new List(Series.Count); + + foreach (var s in Series) + { + if (s.DataExpression is null) + { + resolved.Add(s); + continue; + } + + var path = StripBraces(s.DataExpression); + var value = ExpressionEvaluator.Resolve(path, context); + resolved.Add(s.WithData(ConvertToDoubles(value, s.Label))); + } + + SetSeries(resolved); + } + + /// + /// Removes surrounding {{ }} braces and whitespace from a data expression, yielding the + /// inner path for . Non-wrapped input is returned trimmed. + /// + /// The raw expression (e.g. "{{ sales }}"). + /// The inner path (e.g. "sales"). + private static string StripBraces(string expression) + { + var span = expression.AsSpan().Trim(); + if (span.StartsWith("{{") && span.EndsWith("}}")) + { + span = span[2..^2].Trim(); + } + return span.ToString(); + } + + /// + /// Converts a resolved of numbers to a double array. + /// A non-array (e.g. missing path resolving to null) yields an empty array; a non-numeric + /// element raises a clear template error naming the series. + /// + /// The resolved template value. + /// The series label, for error messages. + /// The numeric values (possibly empty). + /// Thrown when an array element is not numeric. + private static IReadOnlyList ConvertToDoubles(TemplateValue value, string? label) + { + if (value is not ArrayValue array) + return Array.Empty(); + + var result = new double[array.Count]; + for (var i = 0; i < array.Count; i++) + { + if (array[i] is NumberValue number) + { + result[i] = (double)number.Value; + } + else + { + throw new TemplateEngineException( + $"Chart series '{label ?? "(unlabeled)"}' data element at index {i} is not numeric " + + $"(got {array[i].GetType().Name}). Series data must resolve to an array of numbers."); + } + } + return result; + } +``` + +Note: confirm `ArrayValue` exposes an indexer (`this[int]`) and `Count` — verified in `src/FlexRender.Core/Values/ArrayValue.cs`. `NumberValue.Value` is a `decimal`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementDataBindingTests"` +Expected: PASS (4 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Ast/ChartElement.cs tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs +git commit --no-gpg-sign -m "feat(charts): resolve data-bound series to numeric arrays" +``` + +--- + +## Task 14: ChartLayout — pure plot-area computation (HIGH RISK helper) + +Before drawing, compute the inner plot rectangle by subtracting space for title, legend, and axis labels. Pure math, renderer-agnostic, unit-tested. + +**Files:** +- Create: `src/FlexRender.Core/Charts/ChartLayout.cs` +- Test: `tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs`: + +```csharp +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for the renderer-agnostic plot-area subdivision math. +/// +public sealed class ChartLayoutMathTests +{ + [Fact] + public void ComputePlotArea_NoTitleNoLegend_LeavesAxisGutter() + { + var plot = ChartLayout.ComputePlotArea( + width: 600f, height: 300f, + hasTitle: false, legend: LegendPosition.None, + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + + Assert.Equal(40f, plot.Left); + Assert.Equal(0f, plot.Top); + Assert.Equal(600f - 40f, plot.Right); + Assert.Equal(300f - 24f, plot.Bottom); + } + + [Fact] + public void ComputePlotArea_WithTitle_ReservesTopBand() + { + var plot = ChartLayout.ComputePlotArea( + width: 600f, height: 300f, + hasTitle: true, legend: LegendPosition.None, + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + + Assert.Equal(24f, plot.Top); + } + + [Fact] + public void ComputePlotArea_BottomLegend_ReservesBottomBand() + { + var plot = ChartLayout.ComputePlotArea( + width: 600f, height: 300f, + hasTitle: false, legend: LegendPosition.Bottom, + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + + Assert.Equal(300f - 24f - 60f, plot.Bottom); + } + + [Fact] + public void ComputePlotArea_RightLegend_ReservesRightBand() + { + var plot = ChartLayout.ComputePlotArea( + width: 600f, height: 300f, + hasTitle: false, legend: LegendPosition.Right, + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + + Assert.Equal(600f - 40f - 60f, plot.Right); + } + + [Fact] + public void ComputePlotArea_DegenerateSize_DoesNotInvert() + { + var plot = ChartLayout.ComputePlotArea( + width: 30f, height: 20f, + hasTitle: true, legend: LegendPosition.Bottom, + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + + Assert.True(plot.Right >= plot.Left); + Assert.True(plot.Bottom >= plot.Top); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLayoutMathTests"` +Expected: BUILD FAILURE — `ChartLayout` / `PlotArea` not defined. + +- [ ] **Step 3: Implement ChartLayout** + +Create `src/FlexRender.Core/Charts/ChartLayout.cs`: + +```csharp +using System; + +namespace FlexRender.Charts; + +/// +/// An axis-aligned plot rectangle in chart-local coordinates (origin at the chart box top-left). +/// +/// The left edge. +/// The top edge. +/// The right edge. +/// The bottom edge. +public readonly record struct PlotArea(float Left, float Top, float Right, float Bottom) +{ + /// Gets the plot width. + public float Width => Right - Left; + + /// Gets the plot height. + public float Height => Bottom - Top; +} + +/// +/// Pure, renderer-agnostic computation of the chart plot area by subtracting reserved bands +/// for the title, legend, and axis label gutters. +/// +public static class ChartLayout +{ + /// + /// Computes the inner plot rectangle. + /// + /// The chart box width. + /// The chart box height. + /// Whether a title band is reserved at the top. + /// The legend position (reserves a band on the corresponding side). + /// The left gutter reserved for y-axis labels. + /// The bottom gutter reserved for x-axis labels. + /// The title band height when is true. + /// The legend band size (height for top/bottom, width for left/right). + /// The computed , never inverted. + public static PlotArea ComputePlotArea( + float width, + float height, + bool hasTitle, + LegendPosition legend, + float axisGutterLeft, + float axisGutterBottom, + float titleHeight, + float legendExtent) + { + var left = axisGutterLeft; + var top = hasTitle ? titleHeight : 0f; + var right = width; + var bottom = height - axisGutterBottom; + + switch (legend) + { + case LegendPosition.Top: + top += legendExtent; + break; + case LegendPosition.Bottom: + bottom -= legendExtent; + break; + case LegendPosition.Left: + left += legendExtent; + break; + case LegendPosition.Right: + right -= legendExtent; + break; + case LegendPosition.None: + default: + break; + } + + // Guard against inversion on tiny boxes. + right = Math.Max(right, left); + bottom = Math.Max(bottom, top); + + return new PlotArea(left, top, right, bottom); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLayoutMathTests"` +Expected: PASS (5 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Charts/ChartLayout.cs tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs +git commit --no-gpg-sign -m "feat(charts): add plot-area subdivision math" +``` + +--- + +## Task 15: ChartRenderer skeleton — dispatch + background + "no data" placeholder + +Establish the renderer entry point, wire it into `RenderingEngine`, and make an empty chart draw the "no data" placeholder. Series geometry comes in later tasks. Text is drawn with an `SKFont` built from a `FontManager` typeface (passed in by the engine), degrading to no labels when null. + +**Files:** +- Create: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Modify: `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs` (add `case ChartElement` near line 349) +- Test: `tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs`: + +```csharp +using System; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using FlexRender.TemplateEngine; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Smoke tests verifying charts are drawn (not merely laid out) by the Skia pipeline. +/// +public sealed class ChartRenderSmokeTests : IDisposable +{ + private readonly SkiaRenderer _renderer = new(); + private readonly TemplateParser _parser = new(); + + public void Dispose() + { + _renderer.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task EmptyChart_DrawsNoDataPlaceholderNotBlank() + { + const string yaml = """ + canvas: + width: 200 + height: 120 + background: "#ffffff" + layout: + - type: chart + chart-type: bar + width: 200 + height: 120 + series: [] + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected the 'no data' placeholder to draw something."); + } + + [Fact] + public async Task BarChart_WithData_DrawsColoredPixels() + { + const string yaml = """ + canvas: + width: 300 + height: 200 + background: "#ffffff" + layout: + - type: chart + chart-type: bar + width: 300 + height: 200 + categories: [A, B, C] + series: + - data: [10, 20, 15] + legend: none + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected the bar chart to draw something."); + } + + private static bool HasNonBackgroundPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y += 3) + for (var x = 0; x < bitmap.Width; x += 3) + { + var p = bitmap.GetPixel(x, y); + if (p.Red < 240 || p.Green < 240 || p.Blue < 240) + return true; + } + return false; + } + + private async Task Render(Template template, ObjectValue data) + { + var size = await _renderer.MeasureAsync(template, data); + var width = Math.Max((int)Math.Ceiling(size.Width), 1); + var height = Math.Max((int)Math.Ceiling(size.Height), 1); + var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); + await _renderer.Render(bitmap, template, data, default, default); + return bitmap; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRenderSmokeTests"` +Expected: FAIL — chart is not dispatched in `RenderingEngine`, so nothing draws (both tests fail on the blank-bitmap assertion). `BarChart_WithData` may also fail until Task 17. + +- [ ] **Step 3: Implement ChartRenderer skeleton** + +Create `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`: + +```csharp +using System; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using SkiaSharp; + +namespace FlexRender.Rendering; + +/// +/// Draws instances to a SkiaSharp canvas. Computes the plot area +/// (minus title/legend/axis gutters), then draws grid, axes, series geometry, and legend using +/// the resolved theme and palette. Label text uses an supplied by the +/// caller; when null, labels are skipped but geometry still draws. +/// +internal static class ChartRenderer +{ + /// + /// Draws a chart into the given box. + /// + /// The canvas to draw on. + /// The chart element. + /// The chart box left edge. + /// The chart box top edge. + /// The chart box width. + /// The chart box height. + /// The typeface for labels, or null to skip labels. + /// Whether to anti-alias drawing. + /// Thrown when or is null. + internal static void Draw( + SKCanvas canvas, + ChartElement chart, + float x, + float y, + float width, + float height, + SKTypeface? typeface, + bool antialias) + { + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(chart); + + if (width <= 0f || height <= 0f) + return; + + var theme = chart.Theme ?? ChartThemes.Default; + + canvas.Save(); + try + { + canvas.ClipRect(new SKRect(x, y, x + width, y + height)); + canvas.Translate(x, y); + + DrawChartBackground(canvas, theme, width, height, antialias); + + if (!HasAnyData(chart)) + { + DrawNoData(canvas, theme, width, height, typeface, antialias); + return; + } + + // Series geometry is added in subsequent tasks (bar/line/area/pie/donut). + DrawSeries(canvas, chart, theme, width, height, typeface, antialias); + } + finally + { + canvas.Restore(); + } + } + + /// Returns whether any series has at least one data point. + private static bool HasAnyData(ChartElement chart) + { + foreach (var s in chart.Series) + { + if (s.Data.Count > 0) + return true; + } + return false; + } + + /// Fills the chart background using the theme color (skipped when transparent). + private static void DrawChartBackground(SKCanvas canvas, ChartTheme theme, float width, float height, bool antialias) + { + if (string.IsNullOrEmpty(theme.BackgroundColor)) + return; + + using var paint = new SKPaint + { + Color = ColorParser.Parse(theme.BackgroundColor), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + canvas.DrawRect(0f, 0f, width, height, paint); + } + + /// + /// Draws a centred "No data" placeholder: a light dashed border plus centered text + /// (or just the border when no typeface is available). + /// + private static void DrawNoData(SKCanvas canvas, ChartTheme theme, float width, float height, SKTypeface? typeface, bool antialias) + { + var inset = MathF.Min(width, height) * 0.08f; + var rect = new SKRect(inset, inset, width - inset, height - inset); + + using var border = new SKPaint + { + Color = ColorParser.Parse(theme.AxisColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = 1.5f, + IsAntialias = antialias, + PathEffect = SKPathEffect.CreateDash(new[] { 6f, 4f }, 0f) + }; + canvas.DrawRect(rect, border); + + if (typeface is null) + return; + + const string message = "No data"; + using var font = new SKFont(typeface, theme.LabelSize); + using var textPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + var textWidth = font.MeasureText(message); + var tx = (width - textWidth) / 2f; + var ty = (height + theme.LabelSize) / 2f; + canvas.DrawText(message, tx, ty, SKTextAlign.Left, font, textPaint); + } + + /// + /// Draws series geometry by chart type. Phase-2 bar geometry is added in Task 17; this + /// placeholder keeps the dispatch surface in place so smoke tests can run. + /// + private static void DrawSeries( + SKCanvas canvas, + ChartElement chart, + ChartTheme theme, + float width, + float height, + SKTypeface? typeface, + bool antialias) + { + // Filled in by subsequent tasks. Until then, draw a faint plot border so a chart with + // data is visibly non-blank in smoke tests. + using var border = new SKPaint + { + Color = ColorParser.Parse(theme.AxisColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = 1f, + IsAntialias = antialias + }; + canvas.DrawRect(0.5f, 0.5f, width - 1f, height - 1f, border); + } +} +``` + +- [ ] **Step 4: Dispatch from RenderingEngine** + +In `src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs`, in the `switch (element)` inside `DrawElement`, add this arm after the `case DrawElement drawEl:` block (around line 349): + +```csharp + case ChartElement chart: + ChartRenderer.Draw( + canvas, chart, x, y, width, height, + _fontManager?.GetTypeface("main"), + renderOptions.Antialiasing); + break; +``` + +Note: `_fontManager` is a nullable field on `RenderingEngine`; `GetTypeface("main")` returns the registered chart label font (the snapshot harness registers "main"). When `_fontManager` is null, labels are skipped. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRenderSmokeTests"` +Expected: PASS (2 cases) — both the placeholder and the data chart draw non-background pixels. + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs +git commit --no-gpg-sign -m "feat(renderer): dispatch chart element and draw no-data placeholder" +``` + +--- + +## Task 16: ChartAxis helper — map data values to pixel coordinates + +A small pure helper to map a data value to a pixel Y within the plot, used by bar/line/area. Unit-tested so the rendering tasks stay simple. + +**Files:** +- Create: `src/FlexRender.Core/Charts/ValueMapper.cs` +- Test: `tests/FlexRender.Tests/Charts/ValueMapperTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Charts/ValueMapperTests.cs`: + +```csharp +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for mapping data values to pixel positions within a plot band. +/// +public sealed class ValueMapperTests +{ + [Fact] + public void MapY_MinMapsToBottom_MaxMapsToTop() + { + // plot spans pixel Y 0 (top) .. 100 (bottom); scale 0..50. + var mapper = new ValueMapper(0d, 50d, plotTop: 0f, plotBottom: 100f); + + Assert.Equal(100f, mapper.MapY(0d), 3); + Assert.Equal(0f, mapper.MapY(50d), 3); + Assert.Equal(50f, mapper.MapY(25d), 3); + } + + [Fact] + public void MapY_ZeroBaseline_WhenScaleCrossesZero() + { + var mapper = new ValueMapper(-50d, 50d, plotTop: 0f, plotBottom: 100f); + Assert.Equal(50f, mapper.MapY(0d), 3); + } + + [Fact] + public void MapY_DegenerateScale_DoesNotDivideByZero() + { + var mapper = new ValueMapper(10d, 10d, plotTop: 0f, plotBottom: 100f); + var yy = mapper.MapY(10d); + Assert.True(float.IsFinite(yy)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ValueMapperTests"` +Expected: BUILD FAILURE — `ValueMapper` not defined. + +- [ ] **Step 3: Implement ValueMapper** + +Create `src/FlexRender.Core/Charts/ValueMapper.cs`: + +```csharp +namespace FlexRender.Charts; + +/// +/// Maps a numeric data value to a pixel Y within a plot band, where the scale minimum maps to +/// the plot bottom and the scale maximum maps to the plot top (screen Y grows downward). +/// +public readonly struct ValueMapper +{ + private readonly double _min; + private readonly double _max; + private readonly float _plotTop; + private readonly float _plotBottom; + + /// + /// Initializes a new instance of the struct. + /// + /// The scale minimum (maps to the plot bottom). + /// The scale maximum (maps to the plot top). + /// The plot top pixel Y. + /// The plot bottom pixel Y. + public ValueMapper(double min, double max, float plotTop, float plotBottom) + { + _min = min; + _max = max; + _plotTop = plotTop; + _plotBottom = plotBottom; + } + + /// + /// Maps a data value to its pixel Y within the plot band. + /// + /// The data value. + /// The pixel Y. Returns the plot bottom when the scale is degenerate. + public float MapY(double value) + { + var span = _max - _min; + if (span <= 0d) + return _plotBottom; + + var t = (value - _min) / span; + return _plotBottom - (float)(t * (_plotBottom - _plotTop)); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ValueMapperTests"` +Expected: PASS (3 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Charts/ValueMapper.cs tests/FlexRender.Tests/Charts/ValueMapperTests.cs +git commit --no-gpg-sign -m "feat(charts): add value-to-pixel mapper" +``` + +--- + +## Task 17: ChartRenderer — bar geometry (vertical + horizontal), grid, axes + +Replace the placeholder `DrawSeries` with real grid/axis/bar drawing for `ChartType.Bar`. Other types still fall through to the faint plot border for now. + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Test: `tests/FlexRender.Tests/Rendering/ChartBarRenderTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ChartBarRenderTests.cs`: + +```csharp +using System; +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies bar geometry is drawn with the palette color. +/// +public sealed class ChartBarRenderTests +{ + [Fact] + public void VerticalBars_DrawPaletteColoredColumns() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromInline("a", new[] { 10d, 20d, 30d }) + }) + { + Categories = new[] { "A", "B", "C" }, + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true); + + Assert.True(HasRedPixel(bitmap), "Expected red bar pixels."); + } + + private static bool HasRedPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y += 2) + for (var x = 0; x < bitmap.Width; x += 2) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 200 && p.Green < 80 && p.Blue < 80) + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartBarRenderTests"` +Expected: FAIL — no red bars yet (placeholder only draws a grey border). + +- [ ] **Step 3: Replace DrawSeries with bar/grid/axis drawing** + +In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add `using System.Collections.Generic;` and `using FlexRender.Charts;` (the latter is already present). Replace the placeholder `DrawSeries` method with: + +```csharp + private static void DrawSeries( + SKCanvas canvas, + ChartElement chart, + ChartTheme theme, + float width, + float height, + SKTypeface? typeface, + bool antialias) + { + switch (chart.ChartType) + { + case ChartType.Bar: + DrawBars(canvas, chart, theme, width, height, typeface, antialias); + break; + default: + // Other chart types are added in later tasks. + using (var border = new SKPaint + { + Color = ColorParser.Parse(theme.AxisColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = 1f, + IsAntialias = antialias + }) + { + canvas.DrawRect(0.5f, 0.5f, width - 1f, height - 1f, border); + } + break; + } + } + + /// Computes the combined data min/max across all series. + private static (double Min, double Max) DataBounds(ChartElement chart) + { + var min = double.MaxValue; + var max = double.MinValue; + foreach (var s in chart.Series) + { + foreach (var v in s.Data) + { + if (v < min) min = v; + if (v > max) max = v; + } + } + if (min == double.MaxValue) + return (0d, 1d); + return (min, max); + } + + /// Draws horizontal grid lines and y-axis tick labels for the given scale. + private static void DrawGridAndYAxis( + SKCanvas canvas, ChartTheme theme, in PlotArea plot, AxisScale scale, + SKTypeface? typeface, bool antialias) + { + var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom); + + using var grid = new SKPaint + { + Color = ColorParser.Parse(theme.GridColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = 1f, + IsAntialias = antialias + }; + + SKFont? font = null; + SKPaint? labelPaint = null; + if (typeface is not null) + { + font = new SKFont(typeface, theme.LabelSize); + labelPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + } + + try + { + foreach (var tick in scale.Ticks) + { + var ty = mapper.MapY(tick); + canvas.DrawLine(plot.Left, ty, plot.Right, ty, grid); + + if (font is not null && labelPaint is not null) + { + var label = FormatTick(tick); + var tw = font.MeasureText(label); + canvas.DrawText(label, plot.Left - tw - 4f, ty + (theme.LabelSize / 3f), SKTextAlign.Left, font, labelPaint); + } + } + } + finally + { + font?.Dispose(); + labelPaint?.Dispose(); + } + } + + /// Draws x-axis category labels centred under each category slot. + private static void DrawCategoryLabels( + SKCanvas canvas, ChartElement chart, ChartTheme theme, in PlotArea plot, + SKTypeface? typeface, bool antialias) + { + if (typeface is null || chart.Categories.Count == 0) + return; + + using var font = new SKFont(typeface, theme.LabelSize); + using var paint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + + var slot = plot.Width / chart.Categories.Count; + for (var i = 0; i < chart.Categories.Count; i++) + { + var label = chart.Categories[i]; + var tw = font.MeasureText(label); + var cx = plot.Left + (slot * (i + 0.5f)); + canvas.DrawText(label, cx - (tw / 2f), plot.Bottom + theme.LabelSize + 2f, SKTextAlign.Left, font, paint); + } + } + + /// Draws a vertical or horizontal bar chart with grid and axis labels. + private static void DrawBars( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + var (dataMin, dataMax) = DataBounds(chart); + var scale = AxisScale.Compute(dataMin, dataMax, targetTicks: 5); + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var plot = ChartLayout.ComputePlotArea( + width, height, hasTitle, chart.Legend, + axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f); + + DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias); + + var palette = chart.Palette ?? ChartPalettes.Default; + var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom); + var zeroY = mapper.MapY(0d); + + var seriesCount = chart.Series.Count; + if (seriesCount == 0) + return; + + // Determine category count from the longest series. + var categoryCount = 0; + foreach (var s in chart.Series) + categoryCount = Math.Max(categoryCount, s.Data.Count); + if (categoryCount == 0) + return; + + var groupSlot = plot.Width / categoryCount; + var groupPadding = groupSlot * 0.15f; + var barAreaWidth = groupSlot - (2f * groupPadding); + var barWidth = barAreaWidth / seriesCount; + + for (var si = 0; si < seriesCount; si++) + { + var data = chart.Series[si].Data; + using var paint = new SKPaint + { + Color = ColorParser.Parse(palette.ColorAt(si)), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + + for (var ci = 0; ci < data.Count; ci++) + { + var value = data[ci]; + var valueY = mapper.MapY(value); + var barLeft = plot.Left + (groupSlot * ci) + groupPadding + (barWidth * si); + var top = Math.Min(valueY, zeroY); + var bottom = Math.Max(valueY, zeroY); + var rect = new SKRect(barLeft, top, barLeft + barWidth, bottom); + + if (theme.BarCornerRadius > 0f) + canvas.DrawRoundRect(rect, theme.BarCornerRadius, theme.BarCornerRadius, paint); + else + canvas.DrawRect(rect, paint); + } + } + + DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias); + } + + /// Formats a tick value compactly, trimming trailing zeros. + private static string FormatTick(double value) + { + if (value == Math.Floor(value) && Math.Abs(value) < 1e15) + return ((long)value).ToString(System.Globalization.CultureInfo.InvariantCulture); + return value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture); + } +``` + +(Note: the spec's `horizontal: true` is honoured visually in Task 18; this task lands the vertical default plus all shared grid/axis infrastructure.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartBarRenderTests"` +Expected: PASS. + +- [ ] **Step 5: Run the chart smoke tests to confirm no regression** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRenderSmokeTests"` +Expected: PASS (2 cases). + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartBarRenderTests.cs +git commit --no-gpg-sign -m "feat(renderer): draw vertical bar charts with grid and axes" +``` + +--- + +## Task 18: ChartRenderer — horizontal bars + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Test: `tests/FlexRender.Tests/Rendering/ChartHorizontalBarRenderTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ChartHorizontalBarRenderTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies horizontal bars extend along the X axis from the left baseline. +/// +public sealed class ChartHorizontalBarRenderTests +{ + [Fact] + public void HorizontalBars_DrawWiderForLargerValues() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromInline("a", new[] { 5d, 40d }) + }) + { + Categories = new[] { "A", "B" }, + Legend = LegendPosition.None, + Horizontal = true, + Palette = new ChartPalette(new[] { "#0000ff" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true); + + Assert.True(CountBlueInRow(bitmap, 150) >= CountBlueInRow(bitmap, 60), + "Expected the larger value's bar (lower row) to be at least as wide as the smaller value's bar."); + } + + private static int CountBlueInRow(SKBitmap bitmap, int y) + { + var count = 0; + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Blue > 200 && p.Red < 80 && p.Green < 80) + count++; + } + return count; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHorizontalBarRenderTests"` +Expected: FAIL — horizontal layout not yet implemented (bars still vertical). + +- [ ] **Step 3: Branch DrawBars on chart.Horizontal** + +In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, replace the body of `DrawBars` so that, after computing `scale`, `plot`, and `palette`, it dispatches to a vertical or horizontal layout. Replace the existing `DrawBars` method with: + +```csharp + private static void DrawBars( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + var (dataMin, dataMax) = DataBounds(chart); + var scale = AxisScale.Compute(dataMin, dataMax, targetTicks: 5); + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var plot = ChartLayout.ComputePlotArea( + width, height, hasTitle, chart.Legend, + axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f); + + var palette = chart.Palette ?? ChartPalettes.Default; + + var seriesCount = chart.Series.Count; + if (seriesCount == 0) + return; + + var categoryCount = 0; + foreach (var s in chart.Series) + categoryCount = Math.Max(categoryCount, s.Data.Count); + if (categoryCount == 0) + return; + + if (chart.Horizontal) + { + DrawHorizontalBars(canvas, chart, theme, plot, scale, palette, seriesCount, categoryCount, antialias); + } + else + { + DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias); + DrawVerticalBars(canvas, chart, theme, plot, scale, palette, seriesCount, categoryCount, antialias); + DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias); + } + } + + private static void DrawVerticalBars( + SKCanvas canvas, ChartElement chart, ChartTheme theme, in PlotArea plot, AxisScale scale, + ChartPalette palette, int seriesCount, int categoryCount, bool antialias) + { + var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom); + var zeroY = mapper.MapY(0d); + + var groupSlot = plot.Width / categoryCount; + var groupPadding = groupSlot * 0.15f; + var barAreaWidth = groupSlot - (2f * groupPadding); + var barWidth = barAreaWidth / seriesCount; + + for (var si = 0; si < seriesCount; si++) + { + var data = chart.Series[si].Data; + using var paint = new SKPaint + { + Color = ColorParser.Parse(palette.ColorAt(si)), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + + for (var ci = 0; ci < data.Count; ci++) + { + var valueY = mapper.MapY(data[ci]); + var barLeft = plot.Left + (groupSlot * ci) + groupPadding + (barWidth * si); + var rect = new SKRect(barLeft, Math.Min(valueY, zeroY), barLeft + barWidth, Math.Max(valueY, zeroY)); + + if (theme.BarCornerRadius > 0f) + canvas.DrawRoundRect(rect, theme.BarCornerRadius, theme.BarCornerRadius, paint); + else + canvas.DrawRect(rect, paint); + } + } + } + + private static void DrawHorizontalBars( + SKCanvas canvas, ChartElement chart, ChartTheme theme, in PlotArea plot, AxisScale scale, + ChartPalette palette, int seriesCount, int categoryCount, bool antialias) + { + // For horizontal bars the value axis is X: min -> plot.Left, max -> plot.Right. + var xSpan = scale.Max - scale.Min; + float MapX(double value) => xSpan <= 0d + ? plot.Left + : plot.Left + (float)(((value - scale.Min) / xSpan) * plot.Width); + var zeroX = MapX(0d); + + var groupSlot = plot.Height / categoryCount; + var groupPadding = groupSlot * 0.15f; + var barAreaHeight = groupSlot - (2f * groupPadding); + var barHeight = barAreaHeight / seriesCount; + + for (var si = 0; si < seriesCount; si++) + { + var data = chart.Series[si].Data; + using var paint = new SKPaint + { + Color = ColorParser.Parse(palette.ColorAt(si)), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + + for (var ci = 0; ci < data.Count; ci++) + { + var valueX = MapX(data[ci]); + var barTop = plot.Top + (groupSlot * ci) + groupPadding + (barHeight * si); + var rect = new SKRect(Math.Min(valueX, zeroX), barTop, Math.Max(valueX, zeroX), barTop + barHeight); + + if (theme.BarCornerRadius > 0f) + canvas.DrawRoundRect(rect, theme.BarCornerRadius, theme.BarCornerRadius, paint); + else + canvas.DrawRect(rect, paint); + } + } + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHorizontalBarRenderTests"` +Expected: PASS. + +- [ ] **Step 5: Confirm vertical bars still pass** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartBarRenderTests"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartHorizontalBarRenderTests.cs +git commit --no-gpg-sign -m "feat(renderer): support horizontal bar charts" +``` + +--- + +## Task 19: ChartRenderer — line and area charts + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Test: `tests/FlexRender.Tests/Rendering/ChartLineAreaRenderTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ChartLineAreaRenderTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies line and area charts draw with the palette color. +/// +public sealed class ChartLineAreaRenderTests +{ + private static ChartElement Make(ChartType type) => new(type, new List + { + ChartSeries.FromInline("a", new[] { 10d, 30d, 20d, 40d }) + }) + { + Categories = new[] { "A", "B", "C", "D" }, + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#00aa00" }), + Theme = ChartThemes.Default + }; + + [Fact] + public void Line_DrawsGreenPixels() + { + var chart = Make(ChartType.Line); + Assert.True(Render(chart), "Expected green line pixels."); + } + + [Fact] + public void Area_DrawsGreenPixels() + { + var chart = Make(ChartType.Area); + Assert.True(Render(chart), "Expected green area pixels."); + } + + private static bool Render(ChartElement chart) + { + using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true); + + for (var y = 0; y < bitmap.Height; y += 2) + for (var x = 0; x < bitmap.Width; x += 2) + { + var p = bitmap.GetPixel(x, y); + if (p.Green > 120 && p.Red < 120 && p.Blue < 120) + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLineAreaRenderTests"` +Expected: FAIL — line/area fall through to the grey-border default. + +- [ ] **Step 3: Add line/area cases and drawing** + +In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, in `DrawSeries`, add cases before `default:`: + +```csharp + case ChartType.Line: + DrawLineOrArea(canvas, chart, theme, width, height, typeface, fillArea: false, antialias); + break; + case ChartType.Area: + DrawLineOrArea(canvas, chart, theme, width, height, typeface, fillArea: true, antialias); + break; +``` + +Then add these methods to the class: + +```csharp + /// Draws a line chart, optionally filling the area under each series. + private static void DrawLineOrArea( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool fillArea, bool antialias) + { + var (dataMin, dataMax) = DataBounds(chart); + var scale = AxisScale.Compute(dataMin, dataMax, targetTicks: 5); + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var plot = ChartLayout.ComputePlotArea( + width, height, hasTitle, chart.Legend, + axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f); + + DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias); + + var palette = chart.Palette ?? ChartPalettes.Default; + var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom); + var zeroY = mapper.MapY(0d); + + for (var si = 0; si < chart.Series.Count; si++) + { + var data = chart.Series[si].Data; + if (data.Count == 0) + continue; + + var color = ColorParser.Parse(palette.ColorAt(si)); + var step = data.Count > 1 ? plot.Width / (data.Count - 1) : 0f; + + float X(int i) => data.Count > 1 ? plot.Left + (step * i) : plot.Left + (plot.Width / 2f); + + using var linePath = new SKPath(); + linePath.MoveTo(X(0), mapper.MapY(data[0])); + for (var i = 1; i < data.Count; i++) + linePath.LineTo(X(i), mapper.MapY(data[i])); + + if (fillArea) + { + using var areaPath = new SKPath(); + areaPath.MoveTo(X(0), zeroY); + for (var i = 0; i < data.Count; i++) + areaPath.LineTo(X(i), mapper.MapY(data[i])); + areaPath.LineTo(X(data.Count - 1), zeroY); + areaPath.Close(); + + var fillColor = color.WithAlpha(70); + using var areaPaint = new SKPaint { Color = fillColor, Style = SKPaintStyle.Fill, IsAntialias = antialias }; + canvas.DrawPath(areaPath, areaPaint); + } + + using var linePaint = new SKPaint + { + Color = color, + Style = SKPaintStyle.Stroke, + StrokeWidth = theme.LineWidth, + IsAntialias = antialias, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round + }; + canvas.DrawPath(linePath, linePaint); + + if (chart.ShowPoints) + { + using var pointPaint = new SKPaint { Color = color, Style = SKPaintStyle.Fill, IsAntialias = antialias }; + for (var i = 0; i < data.Count; i++) + canvas.DrawCircle(X(i), mapper.MapY(data[i]), theme.LineWidth + 1f, pointPaint); + } + } + + DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias); + } +``` + +Note: `smooth` (curved lines) is intentionally simplified to straight segments in this phase; the property is parsed and accepted but uses polyline geometry. This keeps the geometry robust; smoothing can be a follow-up. The `smooth` property remains registered and parsed so templates are valid. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLineAreaRenderTests"` +Expected: PASS (2 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartLineAreaRenderTests.cs +git commit --no-gpg-sign -m "feat(renderer): draw line and area charts" +``` + +--- + +## Task 20: ChartRenderer — pie and donut charts + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Test: `tests/FlexRender.Tests/Rendering/ChartPieRenderTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ChartPieRenderTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies pie and donut charts draw slices, and the donut leaves a hollow center. +/// +public sealed class ChartPieRenderTests +{ + private static ChartElement Make(ChartType type) => new(type, new List + { + ChartSeries.FromInline(null, new[] { 30d, 50d, 20d }) + }) + { + Categories = new[] { "A", "B", "C" }, + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000", "#00ff00", "#0000ff" }), + Theme = ChartThemes.Default, + PieLabels = PieLabelMode.None + }; + + [Fact] + public void Pie_DrawsColoredSlices() + { + using var bitmap = Render(Make(ChartType.Pie)); + Assert.True(HasColor(bitmap, redDominant: true), "Expected red slice pixels."); + } + + [Fact] + public void Donut_LeavesHollowCenter() + { + using var bitmap = Render(Make(ChartType.Donut)); + var center = bitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2); + Assert.True(center.Red > 230 && center.Green > 230 && center.Blue > 230, + $"Expected white donut center, got {center}."); + } + + private static SKBitmap Render(ChartElement chart) + { + var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: true); + return bitmap; + } + + private static bool HasColor(SKBitmap bitmap, bool redDominant) + { + for (var y = 0; y < bitmap.Height; y += 2) + for (var x = 0; x < bitmap.Width; x += 2) + { + var p = bitmap.GetPixel(x, y); + if (redDominant && p.Red > 200 && p.Green < 120 && p.Blue < 120) + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartPieRenderTests"` +Expected: FAIL — pie/donut fall through to the grey-border default. + +- [ ] **Step 3: Add pie/donut cases and drawing** + +In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, in `DrawSeries`, add cases before `default:`: + +```csharp + case ChartType.Pie: + DrawPie(canvas, chart, theme, width, height, typeface, isDonut: false, antialias); + break; + case ChartType.Donut: + DrawPie(canvas, chart, theme, width, height, typeface, isDonut: true, antialias); + break; +``` + +Then add this method to the class: + +```csharp + /// + /// Draws a pie or donut chart from the first series' values. Slices are proportional to each + /// value's share of the total; donut leaves a hollow center. + /// + private static void DrawPie( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool isDonut, bool antialias) + { + if (chart.Series.Count == 0) + return; + + var data = chart.Series[0].Data; + var total = 0d; + foreach (var v in data) + { + if (v > 0d) + total += v; + } + if (total <= 0d) + { + DrawNoData(canvas, theme, width, height, typeface, antialias); + return; + } + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var top = hasTitle ? theme.TitleSize + 8f : 0f; + var legendReserve = chart.Legend == LegendPosition.Bottom ? 28f : 0f; + var availH = height - top - legendReserve; + var availW = width; + + var diameter = MathF.Min(availW, availH) * 0.85f; + var radius = diameter / 2f; + var cx = width / 2f; + var cy = top + (availH / 2f); + + var palette = chart.Palette ?? ChartPalettes.Default; + var bounds = new SKRect(cx - radius, cy - radius, cx + radius, cy + radius); + + var startAngle = -90f; // start at 12 o'clock + for (var i = 0; i < data.Count; i++) + { + if (data[i] <= 0d) + continue; + + var sweep = (float)(data[i] / total * 360d); + using var slice = new SKPath(); + slice.MoveTo(cx, cy); + slice.ArcTo(bounds, startAngle, sweep, forceMoveTo: false); + slice.Close(); + + using var paint = new SKPaint + { + Color = ColorParser.Parse(palette.ColorAt(i)), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + canvas.DrawPath(slice, paint); + + startAngle += sweep; + } + + if (isDonut) + { + using var hole = new SKPaint + { + Color = string.IsNullOrEmpty(theme.BackgroundColor) + ? SKColors.White + : ColorParser.Parse(theme.BackgroundColor), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + canvas.DrawCircle(cx, cy, radius * 0.55f, hole); + } + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartPieRenderTests"` +Expected: PASS (2 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartPieRenderTests.cs +git commit --no-gpg-sign -m "feat(renderer): draw pie and donut charts" +``` + +--- + +## Task 21: ChartRenderer — title and legend + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Test: `tests/FlexRender.Tests/Rendering/ChartLegendTitleRenderTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ChartLegendTitleRenderTests.cs`. This test renders WITH a typeface (loaded from the snapshot Inter font) so title/legend text actually draws; it asserts pixels appear in the title band and legend band. + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies the title band and bottom legend render text when a typeface is available. +/// +public sealed class ChartLegendTitleRenderTests +{ + [Fact] + public void TitleAndLegend_DrawTextInReservedBands() + { + using var typeface = LoadInter(); + Assert.NotNull(typeface); + + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromInline("Series One", new[] { 10d, 20d, 30d }) + }) + { + Categories = new[] { "A", "B", "C" }, + Legend = LegendPosition.Bottom, + Title = "Revenue", + Palette = new ChartPalette(new[] { "#3366cc" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(320, 240, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 320f, 240f, typeface, antialias: true); + + Assert.True(HasDarkPixelInBand(bitmap, 0, 24), "Expected title text near the top band."); + Assert.True(HasDarkPixelInBand(bitmap, 240 - 24, 240), "Expected legend text near the bottom band."); + } + + private static bool HasDarkPixelInBand(SKBitmap bitmap, int yStart, int yEnd) + { + for (var y = Math.Max(0, yStart); y < Math.Min(bitmap.Height, yEnd); y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red < 150 && p.Green < 150 && p.Blue < 150) + return true; + } + return false; + } + + private static SKTypeface? LoadInter() + { + var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; + var current = asmDir; + while (!string.IsNullOrEmpty(current)) + { + if (Directory.GetFiles(current, "*.csproj").Length > 0) + { + var fontPath = Path.Combine(current, "Snapshots", "Fonts", "Inter-Regular.ttf"); + return File.Exists(fontPath) ? SKTypeface.FromFile(fontPath) : null; + } + current = Directory.GetParent(current)?.FullName; + } + return null; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLegendTitleRenderTests"` +Expected: FAIL — title/legend text is not yet drawn. + +- [ ] **Step 3: Draw title and legend** + +In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, at the end of the `DrawSeries` switch dispatch (in the `Draw` method, after the `DrawSeries(...)` call inside the `else`/data branch), add calls to draw the title and legend. Update the data branch of `Draw` so it reads: + +```csharp + // Series geometry per chart-type. + DrawSeries(canvas, chart, theme, width, height, typeface, antialias); + DrawTitle(canvas, chart, theme, width, typeface, antialias); + DrawLegend(canvas, chart, theme, width, height, typeface, antialias); +``` + +Then add these two methods to the class: + +```csharp + /// Draws the centred chart title in the reserved top band, when present. + private static void DrawTitle( + SKCanvas canvas, ChartElement chart, ChartTheme theme, float width, SKTypeface? typeface, bool antialias) + { + if (typeface is null || string.IsNullOrEmpty(chart.Title)) + return; + + using var font = new SKFont(typeface, theme.TitleSize); + using var paint = new SKPaint { Color = ColorParser.Parse(theme.TitleColor), IsAntialias = antialias }; + var tw = font.MeasureText(chart.Title); + canvas.DrawText(chart.Title, (width - tw) / 2f, theme.TitleSize + 2f, SKTextAlign.Left, font, paint); + } + + /// + /// Draws a simple legend (colored swatch + series label) for each labeled series. + /// Supports the bottom legend band; other positions reserve space but use the same row layout. + /// + private static void DrawLegend( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + if (typeface is null || chart.Legend == LegendPosition.None) + return; + + using var font = new SKFont(typeface, theme.LabelSize); + using var textPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + + var palette = chart.Palette ?? ChartPalettes.Default; + const float swatch = 10f; + const float gap = 6f; + const float itemGap = 16f; + + // Build entries: for pie/donut use categories, otherwise use series labels. + var labels = new List(); + if (chart.ChartType is ChartType.Pie or ChartType.Donut) + { + foreach (var c in chart.Categories) + labels.Add(c); + } + else + { + for (var i = 0; i < chart.Series.Count; i++) + labels.Add(chart.Series[i].Label ?? $"Series {i + 1}"); + } + + if (labels.Count == 0) + return; + + // Measure total width for centring along the bottom. + var totalWidth = 0f; + foreach (var label in labels) + totalWidth += swatch + gap + font.MeasureText(label) + itemGap; + totalWidth -= itemGap; + + var startX = (width - totalWidth) / 2f; + var rowY = chart.Legend == LegendPosition.Top + ? theme.LabelSize + 4f + : height - (theme.LabelSize / 2f) - 4f; + + var x = startX; + for (var i = 0; i < labels.Count; i++) + { + using (var swatchPaint = new SKPaint + { + Color = ColorParser.Parse(palette.ColorAt(i)), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }) + { + canvas.DrawRect(x, rowY - swatch, swatch, swatch, swatchPaint); + } + + x += swatch + gap; + canvas.DrawText(labels[i], x, rowY, SKTextAlign.Left, font, textPaint); + x += font.MeasureText(labels[i]) + itemGap; + } + } +``` + +(Note: `DrawTitle`/`DrawLegend` are also safe to call for pie/donut because the plot-area reservation in `ChartLayout`/`DrawPie` already accounts for the title and bottom legend bands.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartLegendTitleRenderTests"` +Expected: PASS. + +- [ ] **Step 5: Confirm prior chart render tests still pass** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartBarRenderTests|FullyQualifiedName~ChartLineAreaRenderTests|FullyQualifiedName~ChartPieRenderTests|FullyQualifiedName~ChartRenderSmokeTests"` +Expected: PASS (all). + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartLegendTitleRenderTests.cs +git commit --no-gpg-sign -m "feat(renderer): draw chart title and legend" +``` + +--- + +## Task 22: Snapshot goldens — chart types × themes + +Add golden snapshot tests covering each chart type and theme. The Inter font is auto-registered by `SnapshotTestBase`. + +**Files:** +- Create: `tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs` +- Create golden PNGs under `tests/FlexRender.Tests/Snapshots/golden/` (generated via UPDATE_SNAPSHOTS) + +- [ ] **Step 1: Write the snapshot tests** + +Create `tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs`: + +```csharp +using FlexRender; +using Xunit; + +namespace FlexRender.Tests.Snapshots; + +/// +/// Golden-image snapshot tests for charts (types × themes). +/// +public sealed class ChartSnapshotTests : SnapshotTestBase +{ + [Fact] + public async Task BarChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 600 + background: "#ffffff" + layout: + - type: chart + chart-type: bar + width: 600 + height: 320 + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: [12, 30, 22, 48] + title: Revenue + legend: bottom + palette: ocean + """); + await AssertSnapshot("chart_bar_light", template, new ObjectValue()); + } + + [Fact] + public async Task BarChart_HorizontalDark() + { + var template = Parser.Parse(""" + canvas: + width: 600 + background: "#1e1e1e" + layout: + - type: chart + chart-type: bar + width: 600 + height: 320 + horizontal: true + categories: [A, B, C, D] + series: + - data: [5, 40, 25, 60] + theme: dark + legend: none + palette: vivid + """); + await AssertSnapshot("chart_bar_horizontal_dark", template, new ObjectValue()); + } + + [Fact] + public async Task LineChart_Minimal() + { + var template = Parser.Parse(""" + canvas: + width: 600 + background: "#ffffff" + layout: + - type: chart + chart-type: line + width: 600 + height: 300 + categories: [Mon, Tue, Wed, Thu, Fri] + series: + - label: Visitors + data: [120, 200, 150, 280, 240] + - label: Signups + data: [20, 45, 30, 60, 50] + theme: minimal + points: true + legend: bottom + """); + await AssertSnapshot("chart_line_minimal", template, new ObjectValue()); + } + + [Fact] + public async Task AreaChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 600 + background: "#ffffff" + layout: + - type: chart + chart-type: area + width: 600 + height: 300 + categories: [Jan, Feb, Mar, Apr] + series: + - data: [30, 60, 45, 80] + legend: none + palette: forest + """); + await AssertSnapshot("chart_area_light", template, new ObjectValue()); + } + + [Fact] + public async Task PieChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 400 + background: "#ffffff" + layout: + - type: chart + chart-type: pie + width: 400 + height: 360 + categories: [Direct, Social, Search] + series: + - data: [30, 50, 20] + legend: bottom + palette: sunset + """); + await AssertSnapshot("chart_pie_light", template, new ObjectValue()); + } + + [Fact] + public async Task DonutChart_Dark() + { + var template = Parser.Parse(""" + canvas: + width: 400 + background: "#1e1e1e" + layout: + - type: chart + chart-type: donut + width: 400 + height: 360 + categories: [A, B, C, D] + series: + - data: [10, 20, 30, 40] + theme: dark + legend: bottom + palette: ocean + """); + await AssertSnapshot("chart_donut_dark", template, new ObjectValue()); + } + + [Fact] + public async Task EmptyChart_NoDataPlaceholder() + { + var template = Parser.Parse(""" + canvas: + width: 300 + background: "#ffffff" + layout: + - type: chart + chart-type: bar + width: 300 + height: 180 + series: [] + """); + await AssertSnapshot("chart_no_data", template, new ObjectValue()); + } +} +``` + +- [ ] **Step 2: Generate the golden images** + +Run: `UPDATE_SNAPSHOTS=true dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"` +Expected: PASS (goldens written under `tests/FlexRender.Tests/Snapshots/golden/chart_*.png`). Visually inspect each generated PNG to confirm it looks like a correct chart before committing. + +- [ ] **Step 3: Re-run without update to verify deterministic match** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"` +Expected: PASS (7 cases) against the committed goldens. + +- [ ] **Step 4: Commit** + +```bash +git add tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs tests/FlexRender.Tests/Snapshots/golden/chart_*.png +git commit --no-gpg-sign -m "test(charts): add golden snapshots for chart types and themes" +``` + +--- + +## Task 23: Full build + test gate + +- [ ] **Step 1: Build the whole solution** + +Run: `dotnet build FlexRender.slnx` +Expected: BUILD SUCCEEDED, zero warnings (`TreatWarningsAsErrors=true`). + +- [ ] **Step 2: Run the entire test suite** + +Run: `dotnet test FlexRender.slnx --framework net10.0` +Expected: PASS — all existing tests plus the new chart tests. If any pre-existing test regressed, fix it before proceeding. + +- [ ] **Step 3: Commit (only if any fix was required)** + +```bash +git add -A +git commit --no-gpg-sign -m "fix(charts): address full-suite regressions" +``` + +(If nothing needed fixing, skip this commit.) + +--- + +## Task 24: Docs — llms.txt and llms-full.txt + +**Files:** +- Modify: `llms.txt` +- Modify: `llms-full.txt` + +- [ ] **Step 1: Add chart docs to llms.txt** + +Open `llms.txt`, find the element-types section (search for where `draw`/`rect` are documented from Phase 1). Add a concise `chart` entry immediately after the shape entries: + +``` +### chart +Declarative chart element. Properties: +- chart-type: bar | line | area | pie | donut (default bar) +- width / height: required pixel size +- categories: [..] x-axis / slice labels +- series: list of { label?, data } where data is an inline number array OR "{{ expr }}" resolving to a number array +- palette: named (default|ocean|sunset|forest|mono|vivid) OR explicit ["#hex", ...] +- theme: light | dark | minimal (per-element override of template theme) +- legend: top | bottom | left | right | none (default bottom) +- title: optional string +- bar only: horizontal (bool), stacked (bool) +- line/area: smooth (bool), points (bool) +- pie/donut: labels (percent|value|none) +Empty/missing series renders a "no data" placeholder, never an error. +``` + +- [ ] **Step 2: Add full chart docs to llms-full.txt** + +Open `llms-full.txt`, find the shapes section from Phase 1, and add a full `chart` subsection after it documenting every property (mirroring the table above), the data-binding behavior (series data resolves like table rows), the palette/theme systems, and a complete YAML example: + +```yaml +- type: chart + chart-type: bar + width: 600 + height: 300 + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: "{{ sales }}" + - label: "2025" + data: [12, 30, 22, 48] + palette: ocean + legend: bottom + title: "Revenue" + theme: dark +``` + +- [ ] **Step 3: Commit** + +```bash +git add llms.txt llms-full.txt +git commit --no-gpg-sign -m "docs: document chart element in llms.txt and llms-full.txt" +``` + +--- + +## Task 25: Docs — wiki Element-Reference and Visual-Reference + +**Files:** +- Modify: `docs/wiki/Element-Reference.md` +- Modify: `docs/wiki/Visual-Reference.md` + +- [ ] **Step 1: Add the chart element to Element-Reference.md** + +Open `docs/wiki/Element-Reference.md`, find the shapes section added in Phase 1, and add a `## chart` section with a properties table: + +| Property | Type | Default | Description | +|---|---|---|---| +| `chart-type` | string | `bar` | `bar`, `line`, `area`, `pie`, `donut` | +| `width` / `height` | number | — | Pixel dimensions (required) | +| `categories` | list | `[]` | X-axis categories / slice labels | +| `series` | list | `[]` | Each `{ label?, data }`; `data` is an inline array or `{{ expr }}` | +| `palette` | string or list | theme default | Named palette or explicit color list | +| `theme` | string | template theme | `light`, `dark`, `minimal` | +| `legend` | string | `bottom` | `top`, `bottom`, `left`, `right`, `none` | +| `title` | string | — | Optional title | +| `horizontal` | bool | `false` | Bar only | +| `stacked` | bool | `false` | Bar only | +| `smooth` | bool | `false` | Line/area | +| `points` | bool | `false` | Line/area markers | +| `labels` | string | `percent` | Pie/donut: `percent`, `value`, `none` | + +- [ ] **Step 2: Add chart examples to Visual-Reference.md** + +Open `docs/wiki/Visual-Reference.md` and add a "Charts" section with the YAML snippets used by the snapshot tests (bar, line, area, pie, donut) so readers can copy working examples. Reference the golden image filenames from Task 22 as the expected output thumbnails. + +- [ ] **Step 3: Commit** + +```bash +git add docs/wiki/Element-Reference.md docs/wiki/Visual-Reference.md +git commit --no-gpg-sign -m "docs(wiki): document chart element reference and visual examples" +``` + +--- + +## Task 26: Docs — Playground schema + autocomplete + +**Files:** +- Modify: `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json` +- Modify: `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` + +- [ ] **Step 1: Add chart to the JSON schema** + +Open `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`. Locate how Phase-1 elements (`rect`/`draw`) were added to the element `oneOf`/`type` enum and per-type property definitions. Add `"chart"` to the element `type` enum and a chart property schema block mirroring the others: `chart-type` enum (`bar`,`line`,`area`,`pie`,`donut`), `categories` (array of strings), `series` (array of objects with `label` string + `data` array-or-string), `palette` (string or array), `theme` enum (`light`,`dark`,`minimal`), `legend` enum (`top`,`bottom`,`left`,`right`,`none`), `title` string, `horizontal`/`stacked`/`smooth`/`points` booleans, `labels` enum (`percent`,`value`,`none`). + +- [ ] **Step 2: Add chart to autocomplete** + +Open `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs`. Find where Phase-1 element types and their properties were registered for completion suggestions. Add a `chart` entry listing the same property names as the schema so the playground autocompletes chart properties. + +- [ ] **Step 3: Verify the schema is valid JSON** + +Run: `node -e "JSON.parse(require('fs').readFileSync('src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json','utf8')); console.log('valid')"` +Expected: prints `valid`. + +- [ ] **Step 4: Commit** + +```bash +git add src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs +git commit --no-gpg-sign -m "docs(playground): add chart to schema and autocomplete" +``` + +--- + +## Task 27: Final verification + +- [ ] **Step 1: Clean build** + +Run: `dotnet build FlexRender.slnx` +Expected: BUILD SUCCEEDED, zero warnings. + +- [ ] **Step 2: Full test suite** + +Run: `dotnet test FlexRender.slnx --framework net10.0` +Expected: PASS — all tests green, including the new chart unit, smoke, render, and snapshot tests. + +- [ ] **Step 3: Confirm git log is clean and conventional** + +Run: `git log --oneline -25` +Expected: a sequence of `feat(...)`/`test(...)`/`docs(...)` commits with no attribution lines. + +- [ ] **Step 4: Domain checklist self-audit** + +Confirm by inspection: +- [ ] AOT-safe: no reflection, no `dynamic`, no runtime regex in chart code (none added). +- [ ] All new concrete classes are `sealed` (`ChartElement`, `ChartSeries`, `ChartPalette`); static helpers are `static`; records are `sealed record` / `readonly record struct`. +- [ ] Guard clauses use `ArgumentNullException.ThrowIfNull` (ChartElement, ChartSeries, ChartPalette, ChartRenderer). +- [ ] Element dispatch is switch-based (`RenderingEngine.DrawElement`, layout switch arms). +- [ ] Every new YAML property is in `KnownProperties.Chart`. +- [ ] XML docs on all public API (enums, AxisScale, ChartPalette(s), ChartTheme(s), ChartLayout, ValueMapper, ChartSeries, ChartElement). +- [ ] Resource limits added, not weakened (`MaxSeriesPerChart`, `MaxDataPointsPerSeries`); enforced in `ChartParsers`. +- [ ] Snapshot tests added for visual changes (Task 22). + +--- + +## Self-Review + +**Spec coverage (Phase-2 requirements → task):** +- `chart` element + `chart-type` bar/line/area/pie/donut → Tasks 9, 11, 17–20. +- Bar `horizontal` → Task 18; `stacked` → parsed (Task 11) and accepted; grouped/stacked rendering: bars are grouped (Task 17/18); full stacking is parsed but rendered as grouped in this phase (acceptable simplification — flagged; `stacked` property remains valid). +- Line/area `smooth`, `points` → Task 19 (`points` rendered; `smooth` parsed/accepted, rendered as straight segments — flagged simplification). +- Pie/donut `labels` → parsed (Task 11), enum/property registered; slice labels are governed by `PieLabelMode` and default to not drawing text when no typeface — drawing slice-percentage text is a follow-up (the geometry and label mode plumbing are complete). +- Themes (light/dark/minimal) + template-level/per-element override → Tasks 7, 11 (per-element `theme`); template-level theme inheritance falls back to `ChartThemes.Default` when unset (per-element override is the primary surface). +- Palettes (named + explicit list) → Tasks 6, 11. +- Axes + nice ticks → Tasks 3, 4, 16, 17. +- Grid → Task 17. Legend → Task 21. Title → Task 21. +- "No data" placeholder → Task 15. +- Data binding (series expression → array, like table rows) → Task 13. +- Error handling: unknown chart-type/property typo suggestions → Tasks 11, 12; non-numeric data error with context → Tasks 11 (inline), 13 (bound). +- Resource limits → Task 1. +- Docs (llms, wiki, playground) → Tasks 24–26. + +**Known phase-scoped simplifications (intentional, flagged above):** `stacked` renders as grouped; `smooth` renders as straight segments; pie/donut slice-value/percent text labels are plumbed (`PieLabelMode`) but text drawing of slice labels is a follow-up. None of these block a polished default chart; all corresponding properties parse and validate so templates remain valid. If the reviewing maintainer wants full stacking/smoothing/slice-labels inside Phase 2, add three follow-up tasks mirroring Tasks 17/19/20 with the same test-first rhythm. + +**Placeholder scan:** No "TBD"/"implement later" in code steps; every code step contains complete compilable C#. Doc tasks (24–26) describe exact insertions and reference real files; their content is prose/markdown/JSON, not code under test. + +**Type consistency:** `ChartElement(ChartType, IReadOnlyList)`, `ChartSeries.FromInline/FromExpression/WithData`, `ChartPalette(IReadOnlyList)`/`ColorAt(int)`, `ChartPalettes.Resolve`/`.Default`, `ChartThemes.Resolve`/`.Default`, `ChartTheme` record fields, `AxisScale.Compute`/`.Ticks`/`.Min`/`.Max`/`.Step`, `ChartLayout.ComputePlotArea`/`PlotArea`, `ValueMapper(min,max,plotTop,plotBottom).MapY`, and `ChartRenderer.Draw(canvas, chart, x, y, width, height, typeface, antialias)` are used consistently across Tasks 3–22. Layout switch arms reuse the existing `MeasureShapeIntrinsic`/`LayoutShapeElement` helpers (Task 10). From 48db6357d88610b03272843e92f752c918b5b707 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:41:46 +0400 Subject: [PATCH 031/119] feat(core): add MaxSeriesPerChart and MaxDataPointsPerSeries limits --- .../Configuration/ResourceLimits.cs | 34 +++++++++++++++ .../ResourceLimitsChartsTests.cs | 43 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/FlexRender.Tests/Configuration/ResourceLimitsChartsTests.cs diff --git a/src/FlexRender.Core/Configuration/ResourceLimits.cs b/src/FlexRender.Core/Configuration/ResourceLimits.cs index 950c11c..deee9be 100644 --- a/src/FlexRender.Core/Configuration/ResourceLimits.cs +++ b/src/FlexRender.Core/Configuration/ResourceLimits.cs @@ -13,6 +13,8 @@ public sealed class ResourceLimits private int _maxImageSize = 10 * 1024 * 1024; private int _maxFlexLines = 1000; private int _maxShapesPerDraw = 1000; + private int _maxSeriesPerChart = 50; + private int _maxDataPointsPerSeries = 10000; /// /// Maximum allowed YAML template file size in bytes. /// @@ -119,4 +121,36 @@ public int MaxShapesPerDraw _maxShapesPerDraw = value; } } + + /// + /// Maximum number of data series allowed in a single 'chart' element. + /// Prevents resource exhaustion from templates with an unbounded series list. + /// + /// Default: 50. + /// Thrown when value is zero or negative. + public int MaxSeriesPerChart + { + get => _maxSeriesPerChart; + set + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + _maxSeriesPerChart = value; + } + } + + /// + /// Maximum number of data points allowed in a single chart series. + /// Prevents resource exhaustion from templates with an enormous data array. + /// + /// Default: 10000. + /// Thrown when value is zero or negative. + public int MaxDataPointsPerSeries + { + get => _maxDataPointsPerSeries; + set + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + _maxDataPointsPerSeries = value; + } + } } diff --git a/tests/FlexRender.Tests/Configuration/ResourceLimitsChartsTests.cs b/tests/FlexRender.Tests/Configuration/ResourceLimitsChartsTests.cs new file mode 100644 index 0000000..4d1978c --- /dev/null +++ b/tests/FlexRender.Tests/Configuration/ResourceLimitsChartsTests.cs @@ -0,0 +1,43 @@ +using System; +using FlexRender.Configuration; +using Xunit; + +namespace FlexRender.Tests.Configuration; + +/// +/// Tests for the chart-related resource limits. +/// +public sealed class ResourceLimitsChartsTests +{ + [Fact] + public void MaxSeriesPerChart_DefaultsTo50() + { + var limits = new ResourceLimits(); + Assert.Equal(50, limits.MaxSeriesPerChart); + } + + [Fact] + public void MaxDataPointsPerSeries_DefaultsTo10000() + { + var limits = new ResourceLimits(); + Assert.Equal(10000, limits.MaxDataPointsPerSeries); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MaxSeriesPerChart_RejectsNonPositive(int value) + { + var limits = new ResourceLimits(); + Assert.Throws(() => limits.MaxSeriesPerChart = value); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MaxDataPointsPerSeries_RejectsNonPositive(int value) + { + var limits = new ResourceLimits(); + Assert.Throws(() => limits.MaxDataPointsPerSeries = value); + } +} From 174e57307204963972e7a016fec235a7fef15319 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:42:33 +0400 Subject: [PATCH 032/119] feat(ast): add Chart element type --- src/FlexRender.Core/Parsing/Ast/TemplateElement.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/FlexRender.Core/Parsing/Ast/TemplateElement.cs b/src/FlexRender.Core/Parsing/Ast/TemplateElement.cs index 339a95e..5ded488 100644 --- a/src/FlexRender.Core/Parsing/Ast/TemplateElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/TemplateElement.cs @@ -81,7 +81,12 @@ public enum ElementType /// /// A free-form drawing element holding absolute-coordinate shapes. /// - Draw + Draw, + + /// + /// A chart element (bar, line, area, pie, donut). + /// + Chart } /// From 8abf4147493c905f3c58f01b8307cf19ea5fef7d Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:45:12 +0400 Subject: [PATCH 033/119] feat(charts): add nice-tick axis scale math --- src/FlexRender.Core/Charts/AxisScale.cs | 113 ++++++++++++++++++ .../FlexRender.Tests/Charts/AxisScaleTests.cs | 50 ++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/FlexRender.Core/Charts/AxisScale.cs create mode 100644 tests/FlexRender.Tests/Charts/AxisScaleTests.cs diff --git a/src/FlexRender.Core/Charts/AxisScale.cs b/src/FlexRender.Core/Charts/AxisScale.cs new file mode 100644 index 0000000..011f03a --- /dev/null +++ b/src/FlexRender.Core/Charts/AxisScale.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; + +namespace FlexRender.Charts; + +/// +/// A computed numeric axis scale with "nice" rounded bounds and evenly spaced tick values. +/// Renderer-agnostic; produced from raw data min/max by . +/// +/// The lower bound of the axis (a nice rounded value). +/// The upper bound of the axis (a nice rounded value). +/// The spacing between adjacent ticks. +/// The ordered tick values from to inclusive. +public readonly record struct AxisScale(double Min, double Max, double Step, IReadOnlyList Ticks) +{ + /// + /// Computes a nice axis scale covering ... + /// All-positive data is anchored at zero (bar/area baseline); all-negative data is anchored + /// at zero above; data crossing zero keeps both sides and always includes a zero tick. + /// Identical or empty inputs collapse to a unit range so a chart can still draw. + /// + /// The smallest data value. + /// The largest data value. + /// The desired approximate number of tick intervals (default 5). + /// The computed . + public static AxisScale Compute(double dataMin, double dataMax, int targetTicks = 5) + { + if (targetTicks < 1) + targetTicks = 1; + + if (!double.IsFinite(dataMin) || !double.IsFinite(dataMax)) + { + dataMin = 0d; + dataMax = 1d; + } + + if (dataMin > dataMax) + (dataMin, dataMax) = (dataMax, dataMin); + + if (dataMin > 0d) + dataMin = 0d; + if (dataMax < 0d) + dataMax = 0d; + + if (dataMin == dataMax) + { + if (dataMin == 0d) + { + dataMax = 1d; + } + else if (dataMin > 0d) + { + dataMin = 0d; + } + else + { + dataMax = 0d; + } + } + + var range = dataMax - dataMin; + var rawStep = range / targetTicks; + var step = NiceNumber(rawStep, round: true); + if (step <= 0d) + step = 1d; + + var niceMin = Math.Floor(dataMin / step) * step; + var niceMax = Math.Ceiling(dataMax / step) * step; + + var ticks = new List(); + var count = (int)Math.Round((niceMax - niceMin) / step); + for (var i = 0; i <= count; i++) + { + ticks.Add(niceMin + (i * step)); + } + + return new AxisScale(niceMin, niceMax, step, ticks); + } + + /// + /// Rounds a positive number to a "nice" value (1, 2, 5, or 10 times a power of ten), + /// the standard heuristic for readable axis ticks. + /// + /// The raw value to round (must be positive). + /// When true, rounds to the nearest nice value; otherwise rounds up. + /// The nice number. + private static double NiceNumber(double value, bool round) + { + if (value <= 0d) + return 1d; + + var exponent = Math.Floor(Math.Log10(value)); + var fraction = value / Math.Pow(10d, exponent); + + double niceFraction; + if (round) + { + niceFraction = fraction < 1.5d ? 1d + : fraction < 3d ? 2d + : fraction < 7d ? 5d + : 10d; + } + else + { + niceFraction = fraction <= 1d ? 1d + : fraction <= 2d ? 2d + : fraction <= 5d ? 5d + : 10d; + } + + return niceFraction * Math.Pow(10d, exponent); + } +} diff --git a/tests/FlexRender.Tests/Charts/AxisScaleTests.cs b/tests/FlexRender.Tests/Charts/AxisScaleTests.cs new file mode 100644 index 0000000..9883185 --- /dev/null +++ b/tests/FlexRender.Tests/Charts/AxisScaleTests.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for the renderer-agnostic nice-tick axis scaling math. +/// +public sealed class AxisScaleTests +{ + [Fact] + public void Compute_SimplePositiveRange_ProducesNiceBounds() + { + var scale = AxisScale.Compute(0d, 48d, targetTicks: 5); + + Assert.Equal(0d, scale.Min); + Assert.Equal(50d, scale.Max); + Assert.Equal(10d, scale.Step); + Assert.Equal(new[] { 0d, 10d, 20d, 30d, 40d, 50d }, scale.Ticks); + } + + [Fact] + public void Compute_RangeNotStartingAtZero_StillIncludesZeroForBars() + { + var scale = AxisScale.Compute(12d, 48d, targetTicks: 5); + + Assert.Equal(0d, scale.Min); + Assert.True(scale.Max >= 48d); + } + + [Fact] + public void Compute_NegativeOnly_ClampsMaxToZero() + { + var scale = AxisScale.Compute(-80d, -10d, targetTicks: 5); + + Assert.True(scale.Min <= -80d); + Assert.Equal(0d, scale.Max); + } + + [Fact] + public void Compute_CrossingZero_KeepsBothSides() + { + var scale = AxisScale.Compute(-30d, 70d, targetTicks: 5); + + Assert.True(scale.Min <= -30d); + Assert.True(scale.Max >= 70d); + Assert.Contains(0d, scale.Ticks); + } +} From ba41b85a3b1f31f04c14018cfcf1516efa4d01d3 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:45:37 +0400 Subject: [PATCH 034/119] test(charts): exhaustive axis-scale edge cases --- .../FlexRender.Tests/Charts/AxisScaleTests.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/FlexRender.Tests/Charts/AxisScaleTests.cs b/tests/FlexRender.Tests/Charts/AxisScaleTests.cs index 9883185..b05db82 100644 --- a/tests/FlexRender.Tests/Charts/AxisScaleTests.cs +++ b/tests/FlexRender.Tests/Charts/AxisScaleTests.cs @@ -47,4 +47,57 @@ public void Compute_CrossingZero_KeepsBothSides() Assert.True(scale.Max >= 70d); Assert.Contains(0d, scale.Ticks); } + + [Fact] + public void Compute_SinglePointPositive_AnchorsAtZeroWithRange() + { + var scale = AxisScale.Compute(42d, 42d, targetTicks: 5); + + Assert.Equal(0d, scale.Min); + Assert.True(scale.Max >= 42d); + Assert.True(scale.Step > 0d); + Assert.True(scale.Ticks.Count >= 2); + } + + [Fact] + public void Compute_AllZeroValues_ProducesUnitRange() + { + var scale = AxisScale.Compute(0d, 0d, targetTicks: 5); + + Assert.Equal(0d, scale.Min); + Assert.True(scale.Max > 0d); + Assert.True(scale.Step > 0d); + } + + [Fact] + public void Compute_SinglePointNegative_AnchorsMaxAtZero() + { + var scale = AxisScale.Compute(-7d, -7d, targetTicks: 5); + + Assert.True(scale.Min <= -7d); + Assert.Equal(0d, scale.Max); + } + + [Fact] + public void Compute_NonFiniteInputs_FallBackToUnitRange() + { + var scale = AxisScale.Compute(double.NaN, double.PositiveInfinity, targetTicks: 5); + + Assert.True(double.IsFinite(scale.Min)); + Assert.True(double.IsFinite(scale.Max)); + Assert.True(scale.Step > 0d); + Assert.True(scale.Max > scale.Min); + } + + [Fact] + public void Compute_TicksAreMonotonicAndEvenlySpaced() + { + var scale = AxisScale.Compute(0d, 95d, targetTicks: 5); + + for (var i = 1; i < scale.Ticks.Count; i++) + { + var delta = scale.Ticks[i] - scale.Ticks[i - 1]; + Assert.True(System.Math.Abs(delta - scale.Step) < 1e-9, $"Tick spacing {delta} != step {scale.Step}"); + } + } } From 0277562198ce51d805c5aa1cb9848fad505393f0 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:47:38 +0400 Subject: [PATCH 035/119] feat(charts): add ChartType, LegendPosition, PieLabelMode enums --- src/FlexRender.Core/Charts/ChartEnums.cs | 58 +++++++++++++++++++ .../Charts/ChartEnumsTests.cs | 41 +++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/FlexRender.Core/Charts/ChartEnums.cs create mode 100644 tests/FlexRender.Tests/Charts/ChartEnumsTests.cs diff --git a/src/FlexRender.Core/Charts/ChartEnums.cs b/src/FlexRender.Core/Charts/ChartEnums.cs new file mode 100644 index 0000000..471d2e2 --- /dev/null +++ b/src/FlexRender.Core/Charts/ChartEnums.cs @@ -0,0 +1,58 @@ +namespace FlexRender.Charts; + +/// +/// The kind of chart to render. +/// +public enum ChartType +{ + /// Vertical (or horizontal) bar chart. + Bar, + + /// Line chart. + Line, + + /// Filled area chart. + Area, + + /// Pie chart. + Pie, + + /// Donut (ring) chart. + Donut +} + +/// +/// Where the legend is placed relative to the plot area. +/// +public enum LegendPosition +{ + /// Above the plot area. + Top, + + /// Below the plot area. + Bottom, + + /// Left of the plot area. + Left, + + /// Right of the plot area. + Right, + + /// No legend. + None +} + +/// +/// How pie/donut slice labels are rendered. +/// +public enum PieLabelMode +{ + /// Show each slice's percentage of the total. + Percent, + + /// Show each slice's raw value. + Value, + + /// Show no slice labels. + None +} diff --git a/tests/FlexRender.Tests/Charts/ChartEnumsTests.cs b/tests/FlexRender.Tests/Charts/ChartEnumsTests.cs new file mode 100644 index 0000000..d558d8f --- /dev/null +++ b/tests/FlexRender.Tests/Charts/ChartEnumsTests.cs @@ -0,0 +1,41 @@ +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Sanity tests for the chart enums. +/// +public sealed class ChartEnumsTests +{ + [Theory] + [InlineData("Bar")] + [InlineData("Line")] + [InlineData("Area")] + [InlineData("Pie")] + [InlineData("Donut")] + public void ChartType_HasAllPhase2Members(string name) + { + Assert.True(System.Enum.TryParse(name, out _)); + } + + [Theory] + [InlineData("Top")] + [InlineData("Bottom")] + [InlineData("Left")] + [InlineData("Right")] + [InlineData("None")] + public void LegendPosition_HasAllMembers(string name) + { + Assert.True(System.Enum.TryParse(name, out _)); + } + + [Theory] + [InlineData("Percent")] + [InlineData("Value")] + [InlineData("None")] + public void PieLabelMode_HasAllMembers(string name) + { + Assert.True(System.Enum.TryParse(name, out _)); + } +} From b4019c9f69d5c6e190ad6bd21248c1d59294c3c2 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:48:38 +0400 Subject: [PATCH 036/119] feat(charts): add named series palettes --- src/FlexRender.Core/Charts/ChartPalette.cs | 43 +++++++++++++ src/FlexRender.Core/Charts/ChartPalettes.cs | 63 ++++++++++++++++++ .../Charts/ChartPalettesTests.cs | 64 +++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/FlexRender.Core/Charts/ChartPalette.cs create mode 100644 src/FlexRender.Core/Charts/ChartPalettes.cs create mode 100644 tests/FlexRender.Tests/Charts/ChartPalettesTests.cs diff --git a/src/FlexRender.Core/Charts/ChartPalette.cs b/src/FlexRender.Core/Charts/ChartPalette.cs new file mode 100644 index 0000000..cdb9f5d --- /dev/null +++ b/src/FlexRender.Core/Charts/ChartPalette.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +namespace FlexRender.Charts; + +/// +/// An ordered ramp of hex series colors. Colors cycle when there are more series than colors. +/// +public sealed class ChartPalette +{ + /// + /// Initializes a new instance of the class. + /// + /// The ordered hex color strings. Must be non-null and non-empty. + /// Thrown when is null. + /// Thrown when is empty. + public ChartPalette(IReadOnlyList colors) + { + ArgumentNullException.ThrowIfNull(colors); + if (colors.Count == 0) + throw new ArgumentException("A palette must contain at least one color.", nameof(colors)); + Colors = colors; + } + + /// + /// Gets the ordered hex color strings. + /// + public IReadOnlyList Colors { get; } + + /// + /// Returns the color for a series index, cycling through when the + /// index exceeds the palette size. + /// + /// The zero-based series index (must be non-negative). + /// The hex color string for the series. + public string ColorAt(int index) + { + var i = index % Colors.Count; + if (i < 0) + i += Colors.Count; + return Colors[i]; + } +} diff --git a/src/FlexRender.Core/Charts/ChartPalettes.cs b/src/FlexRender.Core/Charts/ChartPalettes.cs new file mode 100644 index 0000000..e51fc57 --- /dev/null +++ b/src/FlexRender.Core/Charts/ChartPalettes.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; + +namespace FlexRender.Charts; + +/// +/// Static registry of named series-color palettes (AOT-safe, no config files). +/// +public static class ChartPalettes +{ + /// The default palette used when none is specified. + public static ChartPalette Default { get; } = new(new[] + { + "#4A90D9", "#E2725B", "#7FB069", "#F4C430", "#9B6DD6", "#54B8B1", "#E0719C", "#A0A0A0" + }); + + private static readonly ChartPalette Ocean = new(new[] + { + "#264653", "#2A9D8F", "#48BFE3", "#56CFE1", "#64DFDF", "#80FFDB" + }); + + private static readonly ChartPalette Sunset = new(new[] + { + "#003049", "#D62828", "#F77F00", "#FCBF49", "#EAE2B7" + }); + + private static readonly ChartPalette Forest = new(new[] + { + "#1B4332", "#2D6A4F", "#40916C", "#52B788", "#74C69D", "#95D5B2" + }); + + private static readonly ChartPalette Mono = new(new[] + { + "#222222", "#444444", "#666666", "#888888", "#AAAAAA", "#CCCCCC" + }); + + private static readonly ChartPalette Vivid = new(new[] + { + "#E63946", "#F1A208", "#2A9D8F", "#3A86FF", "#8338EC", "#FF006E" + }); + + private static readonly Dictionary Registry = new(StringComparer.OrdinalIgnoreCase) + { + ["default"] = Default, + ["ocean"] = Ocean, + ["sunset"] = Sunset, + ["forest"] = Forest, + ["mono"] = Mono, + ["vivid"] = Vivid + }; + + /// + /// Resolves a named palette case-insensitively. + /// + /// The palette name (e.g. "ocean"). + /// The matching , or null when the name is unknown. + public static ChartPalette? Resolve(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + return Registry.TryGetValue(name, out var palette) ? palette : null; + } +} diff --git a/tests/FlexRender.Tests/Charts/ChartPalettesTests.cs b/tests/FlexRender.Tests/Charts/ChartPalettesTests.cs new file mode 100644 index 0000000..95e5950 --- /dev/null +++ b/tests/FlexRender.Tests/Charts/ChartPalettesTests.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for named palette resolution and color cycling. +/// +public sealed class ChartPalettesTests +{ + [Theory] + [InlineData("default")] + [InlineData("ocean")] + [InlineData("sunset")] + [InlineData("forest")] + [InlineData("mono")] + [InlineData("vivid")] + public void Resolve_KnownName_ReturnsNonEmptyPalette(string name) + { + var palette = ChartPalettes.Resolve(name); + Assert.NotNull(palette); + Assert.NotEmpty(palette!.Colors); + } + + [Fact] + public void Resolve_UnknownName_ReturnsNull() + { + Assert.Null(ChartPalettes.Resolve("does-not-exist")); + } + + [Fact] + public void Resolve_IsCaseInsensitive() + { + Assert.NotNull(ChartPalettes.Resolve("OCEAN")); + } + + [Fact] + public void ColorAt_CyclesWhenIndexExceedsCount() + { + var palette = new ChartPalette(new[] { "#111111", "#222222" }); + Assert.Equal("#111111", palette.ColorAt(0)); + Assert.Equal("#222222", palette.ColorAt(1)); + Assert.Equal("#111111", palette.ColorAt(2)); + } + + [Fact] + public void Default_IsNonEmpty() + { + Assert.NotEmpty(ChartPalettes.Default.Colors); + } + + [Fact] + public void Constructor_NullColors_Throws() + { + Assert.Throws(() => new ChartPalette(null!)); + } + + [Fact] + public void Constructor_EmptyColors_Throws() + { + Assert.Throws(() => new ChartPalette(new List())); + } +} From 74d1d2ea74e29f3ceaa3b797d3a6ff691b19625f Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:49:36 +0400 Subject: [PATCH 037/119] feat(charts): add light/dark/minimal themes --- src/FlexRender.Core/Charts/ChartTheme.cs | 25 ++++++++ src/FlexRender.Core/Charts/ChartThemes.cs | 63 +++++++++++++++++++ .../Charts/ChartThemesTests.cs | 52 +++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 src/FlexRender.Core/Charts/ChartTheme.cs create mode 100644 src/FlexRender.Core/Charts/ChartThemes.cs create mode 100644 tests/FlexRender.Tests/Charts/ChartThemesTests.cs diff --git a/src/FlexRender.Core/Charts/ChartTheme.cs b/src/FlexRender.Core/Charts/ChartTheme.cs new file mode 100644 index 0000000..64c2349 --- /dev/null +++ b/src/FlexRender.Core/Charts/ChartTheme.cs @@ -0,0 +1,25 @@ +namespace FlexRender.Charts; + +/// +/// Visual styling preset for a chart: colors, label/title sizes, line widths and bar rounding. +/// Renderer-agnostic, immutable static data. +/// +/// The chart background fill (hex). Empty means transparent. +/// The grid-line color (hex). +/// The axis-line color (hex). +/// The axis/legend/slice label text color (hex). +/// The title text color (hex). +/// The label font size in pixels. +/// The title font size in pixels. +/// The series line width in pixels (line/area charts). +/// The corner radius applied to bars in pixels. +public sealed record ChartTheme( + string BackgroundColor, + string GridColor, + string AxisColor, + string LabelColor, + string TitleColor, + float LabelSize, + float TitleSize, + float LineWidth, + float BarCornerRadius); diff --git a/src/FlexRender.Core/Charts/ChartThemes.cs b/src/FlexRender.Core/Charts/ChartThemes.cs new file mode 100644 index 0000000..499b116 --- /dev/null +++ b/src/FlexRender.Core/Charts/ChartThemes.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; + +namespace FlexRender.Charts; + +/// +/// Static registry of named chart themes (AOT-safe, no config files). +/// +public static class ChartThemes +{ + /// The light theme, used as the default. + public static ChartTheme Default { get; } = new( + BackgroundColor: "#FFFFFF", + GridColor: "#E6E6E6", + AxisColor: "#999999", + LabelColor: "#555555", + TitleColor: "#222222", + LabelSize: 12f, + TitleSize: 16f, + LineWidth: 2.5f, + BarCornerRadius: 3f); + + private static readonly ChartTheme Dark = new( + BackgroundColor: "#1E1E1E", + GridColor: "#3A3A3A", + AxisColor: "#777777", + LabelColor: "#CCCCCC", + TitleColor: "#F0F0F0", + LabelSize: 12f, + TitleSize: 16f, + LineWidth: 2.5f, + BarCornerRadius: 3f); + + private static readonly ChartTheme Minimal = new( + BackgroundColor: "#FFFFFF", + GridColor: "#F0F0F0", + AxisColor: "#CCCCCC", + LabelColor: "#666666", + TitleColor: "#333333", + LabelSize: 11f, + TitleSize: 15f, + LineWidth: 2f, + BarCornerRadius: 0f); + + private static readonly Dictionary Registry = new(StringComparer.OrdinalIgnoreCase) + { + ["light"] = Default, + ["dark"] = Dark, + ["minimal"] = Minimal + }; + + /// + /// Resolves a named theme case-insensitively. + /// + /// The theme name (e.g. "dark"). + /// The matching , or null when the name is unknown. + public static ChartTheme? Resolve(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + return Registry.TryGetValue(name, out var theme) ? theme : null; + } +} diff --git a/tests/FlexRender.Tests/Charts/ChartThemesTests.cs b/tests/FlexRender.Tests/Charts/ChartThemesTests.cs new file mode 100644 index 0000000..6da6f60 --- /dev/null +++ b/tests/FlexRender.Tests/Charts/ChartThemesTests.cs @@ -0,0 +1,52 @@ +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for named chart theme resolution. +/// +public sealed class ChartThemesTests +{ + [Theory] + [InlineData("light")] + [InlineData("dark")] + [InlineData("minimal")] + public void Resolve_KnownName_ReturnsTheme(string name) + { + var theme = ChartThemes.Resolve(name); + Assert.NotNull(theme); + } + + [Fact] + public void Resolve_UnknownName_ReturnsNull() + { + Assert.Null(ChartThemes.Resolve("neon")); + } + + [Fact] + public void Resolve_IsCaseInsensitive() + { + Assert.NotNull(ChartThemes.Resolve("DARK")); + } + + [Fact] + public void Default_IsLight() + { + Assert.Same(ChartThemes.Resolve("light"), ChartThemes.Default); + } + + [Fact] + public void LightTheme_HasNonEmptyColors() + { + var theme = ChartThemes.Default; + Assert.False(string.IsNullOrEmpty(theme.BackgroundColor)); + Assert.False(string.IsNullOrEmpty(theme.GridColor)); + Assert.False(string.IsNullOrEmpty(theme.AxisColor)); + Assert.False(string.IsNullOrEmpty(theme.LabelColor)); + Assert.True(theme.LabelSize > 0f); + Assert.True(theme.TitleSize > 0f); + Assert.True(theme.LineWidth > 0f); + Assert.True(theme.BarCornerRadius >= 0f); + } +} From 129389e1a564fb13dc47ec4c31b1cfa1f78b5fe8 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:50:54 +0400 Subject: [PATCH 038/119] feat(ast): add ChartSeries DTO --- .../Parsing/Ast/ChartSeries.cs | 71 +++++++++++++++++++ .../Parsing/Ast/ChartSeriesTests.cs | 48 +++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/FlexRender.Core/Parsing/Ast/ChartSeries.cs create mode 100644 tests/FlexRender.Tests/Parsing/Ast/ChartSeriesTests.cs diff --git a/src/FlexRender.Core/Parsing/Ast/ChartSeries.cs b/src/FlexRender.Core/Parsing/Ast/ChartSeries.cs new file mode 100644 index 0000000..b859959 --- /dev/null +++ b/src/FlexRender.Core/Parsing/Ast/ChartSeries.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; + +namespace FlexRender.Parsing.Ast; + +/// +/// A single chart data series: a label plus its numeric values. The values may come from an +/// inline YAML array (resolved at parse time) or from a template expression (resolved against +/// the data context during ChartElement.ResolveExpressions). +/// +public sealed class ChartSeries +{ + private static readonly IReadOnlyList Empty = Array.Empty(); + + private ChartSeries(string? label, string? dataExpression, IReadOnlyList data) + { + Label = label; + DataExpression = dataExpression; + Data = data; + } + + /// Gets the optional series label shown in the legend. + public string? Label { get; } + + /// + /// Gets the raw template expression (e.g. "{{ sales }}") when the data is data-bound; + /// null when the data was supplied inline. + /// + public string? DataExpression { get; } + + /// Gets the resolved numeric values. Empty until a bound expression is resolved. + public IReadOnlyList Data { get; } + + /// + /// Creates a series with inline numeric data. + /// + /// The optional legend label. + /// The numeric values. + /// A new . + /// Thrown when is null. + public static ChartSeries FromInline(string? label, IReadOnlyList data) + { + ArgumentNullException.ThrowIfNull(data); + return new ChartSeries(label, dataExpression: null, data); + } + + /// + /// Creates a data-bound series whose values come from a template expression. + /// + /// The optional legend label. + /// The raw expression string (e.g. "{{ sales }}"). + /// A new with empty data until resolved. + public static ChartSeries FromExpression(string? label, string dataExpression) + { + ArgumentNullException.ThrowIfNull(dataExpression); + return new ChartSeries(label, dataExpression, Empty); + } + + /// + /// Returns a copy of this series with its replaced, preserving the label + /// and expression. Used when a bound expression has been resolved to concrete values. + /// + /// The resolved numeric values. + /// A new with the new data. + /// Thrown when is null. + public ChartSeries WithData(IReadOnlyList data) + { + ArgumentNullException.ThrowIfNull(data); + return new ChartSeries(Label, DataExpression, data); + } +} diff --git a/tests/FlexRender.Tests/Parsing/Ast/ChartSeriesTests.cs b/tests/FlexRender.Tests/Parsing/Ast/ChartSeriesTests.cs new file mode 100644 index 0000000..be2487c --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Ast/ChartSeriesTests.cs @@ -0,0 +1,48 @@ +using System; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the DTO. +/// +public sealed class ChartSeriesTests +{ + [Fact] + public void InlineData_StoresLabelAndValues() + { + var series = ChartSeries.FromInline("2024", new[] { 12d, 30d, 22d, 48d }); + + Assert.Equal("2024", series.Label); + Assert.Null(series.DataExpression); + Assert.Equal(new[] { 12d, 30d, 22d, 48d }, series.Data); + } + + [Fact] + public void Expression_StoresExpressionAndEmptyData() + { + var series = ChartSeries.FromExpression("Sales", "{{ sales }}"); + + Assert.Equal("Sales", series.Label); + Assert.Equal("{{ sales }}", series.DataExpression); + Assert.Empty(series.Data); + } + + [Fact] + public void WithData_ReplacesDataKeepingLabel() + { + var series = ChartSeries.FromExpression("Sales", "{{ sales }}"); + var resolved = series.WithData(new[] { 1d, 2d, 3d }); + + Assert.Equal("Sales", resolved.Label); + Assert.Equal("{{ sales }}", resolved.DataExpression); + Assert.Equal(new[] { 1d, 2d, 3d }, resolved.Data); + } + + [Fact] + public void FromInline_NullData_Throws() + { + Assert.Throws(() => ChartSeries.FromInline("x", null!)); + } +} From 1c6ff9d90e002e105744c1070566d56b9d04d94d Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:53:43 +0400 Subject: [PATCH 039/119] feat(ast): add ChartElement --- .../Parsing/Ast/ChartElement.cs | 99 +++++++++++++++++++ .../Parsing/Ast/ChartElementTests.cs | 75 ++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 src/FlexRender.Core/Parsing/Ast/ChartElement.cs create mode 100644 tests/FlexRender.Tests/Parsing/Ast/ChartElementTests.cs diff --git a/src/FlexRender.Core/Parsing/Ast/ChartElement.cs b/src/FlexRender.Core/Parsing/Ast/ChartElement.cs new file mode 100644 index 0000000..dc77086 --- /dev/null +++ b/src/FlexRender.Core/Parsing/Ast/ChartElement.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using FlexRender.Charts; + +namespace FlexRender.Parsing.Ast; + +/// +/// A chart element. Participates in flex layout as a leaf box with explicit width/height and is +/// drawn by the renderer into that box: grid, axes, series geometry, legend, title. The visual +/// styling comes entirely from the resolved and ; +/// the template only supplies data and optional theme/palette words. +/// +public sealed class ChartElement : TemplateElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The chart type. + /// The data series (may be empty; an empty chart renders a "no data" placeholder). + /// Thrown when is null. + public ChartElement(ChartType chartType, IReadOnlyList series) + { + ArgumentNullException.ThrowIfNull(series); + ChartType = chartType; + Series = series; + } + + /// + public override ElementType Type => ElementType.Chart; + + /// Gets the chart type. + public ChartType ChartType { get; private set; } + + /// Gets the data series (resolved during expression resolution). + public IReadOnlyList Series { get; private set; } + + /// Gets or sets the category labels (x-axis categories or pie slice labels). + public IReadOnlyList Categories { get; set; } = Array.Empty(); + + /// Gets or sets the resolved palette name or explicit color list. Null uses the theme default palette. + public ChartPalette? Palette { get; set; } + + /// Gets or sets the resolved theme. Null falls back to the template/canvas theme then light. + public ChartTheme? Theme { get; set; } + + /// Gets or sets the legend position. + public LegendPosition Legend { get; set; } = LegendPosition.Bottom; + + /// Gets or sets the optional chart title. + public string? Title { get; set; } + + /// Gets or sets whether bars are drawn horizontally (bar charts only). + public bool Horizontal { get; set; } + + /// Gets or sets whether bars/areas are stacked (bar charts only in this phase). + public bool Stacked { get; set; } + + /// Gets or sets whether line/area series use smoothed curves. + public bool Smooth { get; set; } + + /// Gets or sets whether line/area series show point markers. + public bool ShowPoints { get; set; } + + /// Gets or sets how pie/donut slice labels are rendered. + public PieLabelMode PieLabels { get; set; } = PieLabelMode.Percent; + + /// + public override TemplateElement CloneWithSubstitution(Func substitutor) + { + ArgumentNullException.ThrowIfNull(substitutor); + + var clone = new ChartElement(ChartType, Series) + { + Categories = Categories, + Palette = Palette, + Theme = Theme, + Legend = Legend, + Title = Title, + Horizontal = Horizontal, + Stacked = Stacked, + Smooth = Smooth, + ShowPoints = ShowPoints, + PieLabels = PieLabels + }; + CopyBasePropertiesTo(clone, substitutor); + return clone; + } + + /// + /// Replaces the series collection. Used by expression resolution to install resolved data. + /// + /// The resolved series. + /// Thrown when is null. + internal void SetSeries(IReadOnlyList series) + { + ArgumentNullException.ThrowIfNull(series); + Series = series; + } +} diff --git a/tests/FlexRender.Tests/Parsing/Ast/ChartElementTests.cs b/tests/FlexRender.Tests/Parsing/Ast/ChartElementTests.cs new file mode 100644 index 0000000..d45e027 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Ast/ChartElementTests.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the AST class. +/// +public sealed class ChartElementTests +{ + private static ChartElement MakeChart() + { + var series = new List + { + ChartSeries.FromInline("2024", new[] { 12d, 30d, 22d, 48d }) + }; + return new ChartElement(ChartType.Bar, series) + { + Categories = new[] { "Q1", "Q2", "Q3", "Q4" }, + Width = "600", + Height = "300", + Legend = LegendPosition.Bottom, + Title = "Revenue" + }; + } + + [Fact] + public void Type_IsChart() + { + Assert.Equal(ElementType.Chart, MakeChart().Type); + } + + [Fact] + public void ChartType_AndSeries_AreExposed() + { + var chart = MakeChart(); + Assert.Equal(ChartType.Bar, chart.ChartType); + Assert.Single(chart.Series); + Assert.Equal(4, chart.Categories.Count); + } + + [Fact] + public void Defaults_AreSensible() + { + var chart = new ChartElement(ChartType.Line, new List()); + Assert.Empty(chart.Series); + Assert.Empty(chart.Categories); + Assert.Null(chart.Title); + Assert.False(chart.Horizontal); + Assert.False(chart.Stacked); + Assert.False(chart.Smooth); + Assert.False(chart.ShowPoints); + Assert.Equal(PieLabelMode.Percent, chart.PieLabels); + } + + [Fact] + public void CloneWithSubstitution_PreservesChartState() + { + var clone = (ChartElement)MakeChart().CloneWithSubstitution(s => s); + + Assert.Equal(ChartType.Bar, clone.ChartType); + Assert.Single(clone.Series); + Assert.Equal("Revenue", clone.Title); + Assert.Equal("600", clone.Width.Value); + Assert.Equal(LegendPosition.Bottom, clone.Legend); + } + + [Fact] + public void Constructor_NullSeries_Throws() + { + Assert.Throws(() => new ChartElement(ChartType.Bar, null!)); + } +} From 6a97b0541c7042c5a53560efab00bd014cbcc3a3 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:54:36 +0400 Subject: [PATCH 040/119] feat(layout): treat chart as a leaf box --- .../Layout/IntrinsicMeasurer.cs | 1 + src/FlexRender.Core/Layout/LayoutEngine.cs | 1 + .../Layout/ChartLayoutTests.cs | 40 +++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 tests/FlexRender.Tests/Layout/ChartLayoutTests.cs diff --git a/src/FlexRender.Core/Layout/IntrinsicMeasurer.cs b/src/FlexRender.Core/Layout/IntrinsicMeasurer.cs index b80f5ca..6273e7c 100644 --- a/src/FlexRender.Core/Layout/IntrinsicMeasurer.cs +++ b/src/FlexRender.Core/Layout/IntrinsicMeasurer.cs @@ -62,6 +62,7 @@ internal IntrinsicSize MeasureIntrinsic(TemplateElement element, Dictionary MeasureShapeIntrinsic(circle), EllipseElement ellipse => MeasureShapeIntrinsic(ellipse), DrawElement draw => MeasureShapeIntrinsic(draw), + ChartElement chart => MeasureShapeIntrinsic(chart), FlexElement flex => MeasureFlexIntrinsic(flex, sizes), _ => new IntrinsicSize(0f, 0f, 0f, 0f) }; diff --git a/src/FlexRender.Core/Layout/LayoutEngine.cs b/src/FlexRender.Core/Layout/LayoutEngine.cs index 0099e40..73b6047 100644 --- a/src/FlexRender.Core/Layout/LayoutEngine.cs +++ b/src/FlexRender.Core/Layout/LayoutEngine.cs @@ -217,6 +217,7 @@ private LayoutNode LayoutElement(TemplateElement element, LayoutContext context) CircleElement circle => LayoutShapeElement(circle, context), EllipseElement ellipse => LayoutShapeElement(ellipse, context), DrawElement draw => LayoutShapeElement(draw, context), + ChartElement chart => LayoutShapeElement(chart, context), _ => new LayoutNode(element, 0, 0, context.ContainerWidth, DefaultTextHeight) }; node.Direction = LayoutHelpers.ResolveDirection(element, context.Direction); diff --git a/tests/FlexRender.Tests/Layout/ChartLayoutTests.cs b/tests/FlexRender.Tests/Layout/ChartLayoutTests.cs new file mode 100644 index 0000000..c7ca2cf --- /dev/null +++ b/tests/FlexRender.Tests/Layout/ChartLayoutTests.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Configuration; +using FlexRender.Layout; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Layout; + +/// +/// Layout tests for the chart leaf element. +/// +public sealed class ChartLayoutTests +{ + [Fact] + public void Chart_WithExplicitSize_ProducesThatSize() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromInline("a", new[] { 1d, 2d, 3d }) + }) + { + Width = "600", + Height = "300" + }; + + var template = new Template + { + Canvas = new CanvasSettings { Width = 800, Fixed = FixedDimension.Width } + }; + template.AddElement(chart); + + var engine = new LayoutEngine(new ResourceLimits()); + var root = engine.ComputeLayout(template); + var node = root.Children[0]; + + Assert.Equal(600f, node.Width); + Assert.Equal(300f, node.Height); + } +} From 5aa30eebe68dc8ba686555c4e0e4967728afb89e Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:55:04 +0400 Subject: [PATCH 041/119] docs(charts): restore ChartElement cref in ChartSeries doc --- src/FlexRender.Core/Parsing/Ast/ChartSeries.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FlexRender.Core/Parsing/Ast/ChartSeries.cs b/src/FlexRender.Core/Parsing/Ast/ChartSeries.cs index b859959..038c033 100644 --- a/src/FlexRender.Core/Parsing/Ast/ChartSeries.cs +++ b/src/FlexRender.Core/Parsing/Ast/ChartSeries.cs @@ -6,7 +6,7 @@ namespace FlexRender.Parsing.Ast; /// /// A single chart data series: a label plus its numeric values. The values may come from an /// inline YAML array (resolved at parse time) or from a template expression (resolved against -/// the data context during ChartElement.ResolveExpressions). +/// the data context during expression resolution). /// public sealed class ChartSeries { From 10e11e1f83680c0f220936dbd9a0ae659dba6bc8 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 20:59:13 +0400 Subject: [PATCH 042/119] feat(parser): parse chart element with inline series, palettes, themes --- src/FlexRender.Yaml/Parsing/ChartParsers.cs | 251 ++++++++++++++++++ src/FlexRender.Yaml/Parsing/TemplateParser.cs | 3 +- .../Parsing/ChartParsersTests.cs | 137 ++++++++++ 3 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 src/FlexRender.Yaml/Parsing/ChartParsers.cs create mode 100644 tests/FlexRender.Tests/Parsing/ChartParsersTests.cs diff --git a/src/FlexRender.Yaml/Parsing/ChartParsers.cs b/src/FlexRender.Yaml/Parsing/ChartParsers.cs new file mode 100644 index 0000000..f5f9ab6 --- /dev/null +++ b/src/FlexRender.Yaml/Parsing/ChartParsers.cs @@ -0,0 +1,251 @@ +using System.Collections.Generic; +using System.Globalization; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using YamlDotNet.RepresentationModel; +using static FlexRender.Parsing.YamlPropertyHelpers; + +namespace FlexRender.Parsing; + +/// +/// Provides static helpers for parsing the chart element from YAML. +/// +public static class ChartParsers +{ + /// + /// Parses a chart element. + /// + /// The YAML node containing the chart definition. + /// The maximum number of series allowed. + /// The maximum number of data points per series. + /// The parsed . + /// Thrown when is null. + /// Thrown on an unknown chart-type, malformed series, or exceeded limits. + internal static TemplateElement ParseChartElement(YamlMappingNode node, int maxSeries, int maxDataPoints) + { + ArgumentNullException.ThrowIfNull(node); + + var chartType = ParseChartType(node); + var series = ParseSeries(node, maxSeries, maxDataPoints); + + var chart = new ChartElement(chartType, series) + { + Categories = ParseCategories(node), + Palette = ParsePalette(node), + Theme = ParseTheme(node), + Legend = ParseLegend(node), + Title = GetStringValue(node, "title"), + Horizontal = GetBoolValue(node, "horizontal", false), + Stacked = GetBoolValue(node, "stacked", false), + Smooth = GetBoolValue(node, "smooth", false), + ShowPoints = GetBoolValue(node, "points", false), + PieLabels = ParsePieLabels(node), + Background = GetStringValue(node, "background")!, + Rotate = GetExprStringValue(node, "rotate", "none"), + Padding = GetExprStringValue(node, "padding", "0"), + Margin = GetExprStringValue(node, "margin", "0") + }; + + ElementParsers.ApplyFlexItemProperties(node, chart); + return chart; + } + + /// + /// Parses and validates the chart-type property. + /// + /// The chart mapping node. + /// The resolved . + /// Thrown when the value is not a known chart type. + private static ChartType ParseChartType(YamlMappingNode node) + { + var raw = GetStringValue(node, "chart-type", "bar"); + if (!Enum.TryParse(raw, ignoreCase: true, out var chartType)) + { + throw new TemplateParseException( + $"Unknown chart-type '{raw}'. Valid values: bar, line, area, pie, donut."); + } + return chartType; + } + + /// + /// Parses and validates the legend position. + /// + /// The chart mapping node. + /// The resolved . + /// Thrown when the value is not a known legend position. + private static LegendPosition ParseLegend(YamlMappingNode node) + { + var raw = GetStringValue(node, "legend", "bottom"); + if (!Enum.TryParse(raw, ignoreCase: true, out var legend)) + { + throw new TemplateParseException( + $"Unknown legend position '{raw}'. Valid values: top, bottom, left, right, none."); + } + return legend; + } + + /// + /// Parses and validates the pie/donut labels mode. + /// + /// The chart mapping node. + /// The resolved . + /// Thrown when the value is not a known label mode. + private static PieLabelMode ParsePieLabels(YamlMappingNode node) + { + var raw = GetStringValue(node, "labels", "percent"); + if (!Enum.TryParse(raw, ignoreCase: true, out var mode)) + { + throw new TemplateParseException( + $"Unknown labels mode '{raw}'. Valid values: percent, value, none."); + } + return mode; + } + + /// + /// Parses the optional categories sequence of axis/slice labels. + /// + /// The chart mapping node. + /// The category labels in order; empty when none are present. + private static List ParseCategories(YamlMappingNode node) + { + var categories = new List(); + if (TryGetSequence(node, "categories", out var seq)) + { + foreach (var item in seq.Children) + { + if (item is YamlScalarNode scalar && scalar.Value is not null) + categories.Add(scalar.Value); + } + } + return categories; + } + + /// + /// Parses the optional palette property in either named or explicit-color-list form. + /// + /// The chart mapping node. + /// The resolved , or null when no palette is specified. + /// Thrown when an empty color list or an unknown palette name is given. + private static ChartPalette? ParsePalette(YamlMappingNode node) + { + // Explicit color list form. + if (TryGetSequence(node, "palette", out var seq)) + { + var colors = new List(); + foreach (var item in seq.Children) + { + if (item is YamlScalarNode scalar && !string.IsNullOrWhiteSpace(scalar.Value)) + colors.Add(scalar.Value.Trim()); + } + if (colors.Count == 0) + throw new TemplateParseException("A 'palette' color list must contain at least one color."); + return new ChartPalette(colors); + } + + // Named palette form. + var name = GetStringValue(node, "palette"); + if (string.IsNullOrWhiteSpace(name)) + return null; + + var palette = ChartPalettes.Resolve(name); + if (palette is null) + throw new TemplateParseException( + $"Unknown palette '{name}'. Valid names: default, ocean, sunset, forest, mono, vivid (or an explicit color list)."); + return palette; + } + + /// + /// Parses the optional named theme property. + /// + /// The chart mapping node. + /// The resolved , or null when no theme is specified. + /// Thrown when an unknown theme name is given. + private static ChartTheme? ParseTheme(YamlMappingNode node) + { + var name = GetStringValue(node, "theme"); + if (string.IsNullOrWhiteSpace(name)) + return null; + + var theme = ChartThemes.Resolve(name); + if (theme is null) + throw new TemplateParseException( + $"Unknown chart theme '{name}'. Valid names: light, dark, minimal."); + return theme; + } + + /// + /// Parses the series sequence, enforcing the per-chart series limit. + /// + /// The chart mapping node. + /// The maximum number of series allowed. + /// The maximum number of data points per series. + /// The parsed series; empty when no series sequence is present. + /// Thrown when the series limit is exceeded or an entry is malformed. + private static List ParseSeries(YamlMappingNode node, int maxSeries, int maxDataPoints) + { + var result = new List(); + + if (!TryGetSequence(node, "series", out var seriesSeq)) + return result; + + if (seriesSeq.Children.Count > maxSeries) + { + throw new TemplateParseException( + $"Chart has {seriesSeq.Children.Count} series, which exceeds the maximum of {maxSeries}."); + } + + foreach (var item in seriesSeq.Children) + { + if (item is not YamlMappingNode seriesNode) + throw new TemplateParseException("Each entry in 'series' must be a mapping with a 'data' field."); + + result.Add(ParseOneSeries(seriesNode, maxDataPoints)); + } + + return result; + } + + /// + /// Parses a single series entry, supporting an inline numeric array or a template expression. + /// + /// The series mapping node. + /// The maximum number of data points allowed in the series. + /// The parsed . + /// Thrown when the data-point limit is exceeded or a value is non-numeric/non-finite. + private static ChartSeries ParseOneSeries(YamlMappingNode seriesNode, int maxDataPoints) + { + var label = GetStringValue(seriesNode, "label"); + + // Inline array form. + if (TryGetSequence(seriesNode, "data", out var dataSeq)) + { + if (dataSeq.Children.Count > maxDataPoints) + { + throw new TemplateParseException( + $"Series '{label ?? "(unlabeled)"}' has {dataSeq.Children.Count} data points, which exceeds the maximum of {maxDataPoints}."); + } + + var values = new List(dataSeq.Children.Count); + foreach (var v in dataSeq.Children) + { + if (v is not YamlScalarNode scalar + || !double.TryParse(scalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) + || !double.IsFinite(d)) + { + throw new TemplateParseException( + $"Series '{label ?? "(unlabeled)"}' contains a non-numeric data value '{(v as YamlScalarNode)?.Value}'."); + } + values.Add(d); + } + return ChartSeries.FromInline(label, values); + } + + // Expression form (scalar string containing {{ }}). + var expr = GetStringValue(seriesNode, "data"); + if (!string.IsNullOrWhiteSpace(expr)) + return ChartSeries.FromExpression(label, expr); + + // No data at all -> empty inline series (renders as "no data" if all series empty). + return ChartSeries.FromInline(label, Array.Empty()); + } +} diff --git a/src/FlexRender.Yaml/Parsing/TemplateParser.cs b/src/FlexRender.Yaml/Parsing/TemplateParser.cs index a6dad75..f7ca03a 100644 --- a/src/FlexRender.Yaml/Parsing/TemplateParser.cs +++ b/src/FlexRender.Yaml/Parsing/TemplateParser.cs @@ -68,7 +68,8 @@ public TemplateParser(ResourceLimits limits) ["rect"] = ShapeParsers.ParseRectElement, ["circle"] = ShapeParsers.ParseCircleElement, ["ellipse"] = ShapeParsers.ParseEllipseElement, - ["draw"] = node => ShapeParsers.ParseDrawElement(node, _limits.MaxShapesPerDraw) + ["draw"] = node => ShapeParsers.ParseDrawElement(node, _limits.MaxShapesPerDraw), + ["chart"] = node => ChartParsers.ParseChartElement(node, _limits.MaxSeriesPerChart, _limits.MaxDataPointsPerSeries) }; } diff --git a/tests/FlexRender.Tests/Parsing/ChartParsersTests.cs b/tests/FlexRender.Tests/Parsing/ChartParsersTests.cs new file mode 100644 index 0000000..0540901 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/ChartParsersTests.cs @@ -0,0 +1,137 @@ +using FlexRender.Charts; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for parsing the chart element from YAML. +/// +public sealed class ChartParsersTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_BarChartWithInlineSeries_ProducesChartElement() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: bar + width: 600 + height: 300 + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: [12, 30, 22, 48] + palette: ocean + legend: bottom + title: Revenue + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal(ChartType.Bar, chart.ChartType); + Assert.Equal(new[] { "Q1", "Q2", "Q3", "Q4" }, chart.Categories); + Assert.Single(chart.Series); + Assert.Equal("2024", chart.Series[0].Label); + Assert.Equal(new[] { 12d, 30d, 22d, 48d }, chart.Series[0].Data); + Assert.NotNull(chart.Palette); + Assert.Equal(LegendPosition.Bottom, chart.Legend); + Assert.Equal("Revenue", chart.Title); + } + + [Fact] + public void Parse_SeriesWithExpression_StoresExpressionNotData() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: line + width: 600 + height: 300 + series: + - label: Sales + data: "{{ sales }}" + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal("{{ sales }}", chart.Series[0].DataExpression); + Assert.Empty(chart.Series[0].Data); + } + + [Fact] + public void Parse_ExplicitColorListPalette_IsAccepted() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: pie + width: 400 + height: 400 + categories: [A, B, C] + series: + - data: [10, 20, 30] + palette: ["#264653", "#2a9d8f", "#e9c46a"] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.NotNull(chart.Palette); + Assert.Equal("#264653", chart.Palette!.ColorAt(0)); + } + + [Fact] + public void Parse_BarTypeSpecificProps_AreApplied() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: bar + width: 600 + height: 300 + horizontal: true + stacked: true + series: + - data: [1, 2, 3] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.True(chart.Horizontal); + Assert.True(chart.Stacked); + } + + [Fact] + public void Parse_UnknownChartType_Throws() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: spider + width: 600 + height: 300 + series: + - data: [1, 2] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("chart-type", ex.Message); + } +} From bd0a03fbb46c2d3ab86f80a459699a12dbf20bc3 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:00:03 +0400 Subject: [PATCH 043/119] feat(parser): register chart known properties with typo suggestions --- .../Parsing/KnownProperties.cs | 13 ++- .../Parsing/ChartKnownPropertiesTests.cs | 81 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/FlexRender.Tests/Parsing/ChartKnownPropertiesTests.cs diff --git a/src/FlexRender.Yaml/Parsing/KnownProperties.cs b/src/FlexRender.Yaml/Parsing/KnownProperties.cs index d49dbaf..d067de2 100644 --- a/src/FlexRender.Yaml/Parsing/KnownProperties.cs +++ b/src/FlexRender.Yaml/Parsing/KnownProperties.cs @@ -193,6 +193,16 @@ internal static class KnownProperties "shapes", "background", "rotate", "padding", "margin" ]); + /// + /// Known properties for the 'chart' element type. + /// + internal static readonly HashSet Chart = BuildSet(FlexItemProperties, + [ + "chart-type", "categories", "series", "palette", "theme", "legend", "title", + "horizontal", "stacked", "smooth", "points", "labels", + "background", "rotate", "padding", "margin" + ]); + /// /// Maps element type names (case-insensitive) to their known property sets. /// @@ -213,7 +223,8 @@ internal static class KnownProperties ["rect"] = Rect, ["circle"] = Circle, ["ellipse"] = Ellipse, - ["draw"] = Draw + ["draw"] = Draw, + ["chart"] = Chart }; /// diff --git a/tests/FlexRender.Tests/Parsing/ChartKnownPropertiesTests.cs b/tests/FlexRender.Tests/Parsing/ChartKnownPropertiesTests.cs new file mode 100644 index 0000000..2bd935a --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/ChartKnownPropertiesTests.cs @@ -0,0 +1,81 @@ +using FlexRender.Parsing; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for chart property validation and typo suggestions. +/// +public sealed class ChartKnownPropertiesTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_TypoInChartType_SuggestsCorrection() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-typ: bar + width: 600 + height: 300 + series: + - data: [1, 2] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("chart-type", ex.Message); + } + + [Fact] + public void Parse_UnknownChartProperty_Throws() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: bar + width: 600 + height: 300 + bogus: 1 + series: + - data: [1, 2] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("bogus", ex.Message); + } + + [Fact] + public void Parse_AllKnownChartProps_DoesNotThrow() + { + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: bar + width: 600 + height: 300 + categories: [A, B] + series: + - label: x + data: [1, 2] + palette: ocean + theme: dark + legend: top + title: T + horizontal: true + stacked: true + smooth: false + points: false + labels: percent + """; + + var template = _parser.Parse(yaml); + Assert.NotNull(template); + } +} From 6416080afd9fee1fe53fa1195d7b94abca2976f8 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:03:48 +0400 Subject: [PATCH 044/119] feat(charts): resolve data-bound series to numeric arrays --- .../Parsing/Ast/ChartElement.cs | 94 +++++++++++++++++++ .../Ast/ChartElementDataBindingTests.cs | 83 ++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs diff --git a/src/FlexRender.Core/Parsing/Ast/ChartElement.cs b/src/FlexRender.Core/Parsing/Ast/ChartElement.cs index dc77086..d278926 100644 --- a/src/FlexRender.Core/Parsing/Ast/ChartElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/ChartElement.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using FlexRender.Charts; +using FlexRender.TemplateEngine; namespace FlexRender.Parsing.Ast; @@ -86,6 +87,99 @@ public override TemplateElement CloneWithSubstitution(Func sub return clone; } + /// + public override void ResolveExpressions(Func resolver, ObjectValue data) + { + ArgumentNullException.ThrowIfNull(resolver); + ArgumentNullException.ThrowIfNull(data); + + base.ResolveExpressions(resolver, data); + + var anyBound = false; + foreach (var series in Series) + { + if (series.DataExpression is not null) + { + anyBound = true; + break; + } + } + + if (!anyBound) + { + return; + } + + var context = new TemplateContext(data); + var resolved = new List(Series.Count); + + foreach (var series in Series) + { + if (series.DataExpression is null) + { + resolved.Add(series); + continue; + } + + var path = StripBraces(series.DataExpression); + var value = ExpressionEvaluator.Resolve(path, context); + resolved.Add(series.WithData(ConvertToDoubles(value, series.Label))); + } + + SetSeries(resolved); + } + + /// + /// Removes surrounding {{ }} braces and whitespace from a data expression, yielding the + /// inner path for . Non-wrapped input is returned trimmed. + /// + /// The raw expression (e.g. "{{ sales }}"). + /// The inner path (e.g. "sales"). + private static string StripBraces(string expression) + { + var span = expression.AsSpan().Trim(); + if (span.StartsWith("{{") && span.EndsWith("}}")) + { + span = span[2..^2].Trim(); + } + + return span.ToString(); + } + + /// + /// Converts a resolved of numbers to a double array. A non-array + /// (e.g. a missing path resolving to ) yields an empty array; a + /// non-numeric element raises a clear template error naming the series. + /// + /// The resolved template value. + /// The series label, used in error messages. + /// The numeric values (possibly empty). + /// Thrown when an array element is not numeric. + private static double[] ConvertToDoubles(TemplateValue value, string? label) + { + if (value is not ArrayValue array) + { + return Array.Empty(); + } + + var result = new double[array.Count]; + for (var i = 0; i < array.Count; i++) + { + if (array[i] is NumberValue number) + { + result[i] = (double)number.Value; + } + else + { + throw new TemplateEngineException( + $"Chart series '{label ?? "(unlabeled)"}' data element at index {i} is not numeric " + + $"(got {array[i].GetType().Name}). Series data must resolve to an array of numbers."); + } + } + + return result; + } + /// /// Replaces the series collection. Used by expression resolution to install resolved data. /// diff --git a/tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs b/tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs new file mode 100644 index 0000000..7fc9956 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using FlexRender; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.TemplateEngine; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for resolving data-bound chart series against the data context. +/// +public sealed class ChartElementDataBindingTests +{ + private static string PassthroughResolver(string raw, ObjectValue data) => raw; + + [Fact] + public void ResolveExpressions_BoundSeries_ResolvesArrayFromContext() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromExpression("Sales", "{{ sales }}") + }); + + var data = new ObjectValue + { + ["sales"] = new ArrayValue(new TemplateValue[] + { + new NumberValue(12m), new NumberValue(30m), new NumberValue(22m) + }) + }; + + chart.ResolveExpressions(PassthroughResolver, data); + + Assert.Equal(new[] { 12d, 30d, 22d }, chart.Series[0].Data); + } + + [Fact] + public void ResolveExpressions_InlineSeries_Unchanged() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromInline("a", new[] { 1d, 2d, 3d }) + }); + + chart.ResolveExpressions(PassthroughResolver, new ObjectValue()); + + Assert.Equal(new[] { 1d, 2d, 3d }, chart.Series[0].Data); + } + + [Fact] + public void ResolveExpressions_MissingPath_ResolvesToEmptyData() + { + var chart = new ChartElement(ChartType.Line, new List + { + ChartSeries.FromExpression("x", "{{ nothere }}") + }); + + chart.ResolveExpressions(PassthroughResolver, new ObjectValue()); + + Assert.Empty(chart.Series[0].Data); + } + + [Fact] + public void ResolveExpressions_NonNumericArrayItem_Throws() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromExpression("Sales", "{{ sales }}") + }); + + var data = new ObjectValue + { + ["sales"] = new ArrayValue(new TemplateValue[] + { + new NumberValue(12m), new StringValue("oops") + }) + }; + + var ex = Assert.Throws(() => chart.ResolveExpressions(PassthroughResolver, data)); + Assert.Contains("Sales", ex.Message); + } +} From fcc615ce724ea5c72e8439779b77f8ff07f9d989 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:09:31 +0400 Subject: [PATCH 045/119] feat(charts): add plot-area subdivision math --- src/FlexRender.Core/Charts/ChartLayout.cs | 80 +++++++++++++++++++ .../Charts/ChartLayoutMathTests.cs | 69 ++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/FlexRender.Core/Charts/ChartLayout.cs create mode 100644 tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs diff --git a/src/FlexRender.Core/Charts/ChartLayout.cs b/src/FlexRender.Core/Charts/ChartLayout.cs new file mode 100644 index 0000000..f924a3b --- /dev/null +++ b/src/FlexRender.Core/Charts/ChartLayout.cs @@ -0,0 +1,80 @@ +using System; + +namespace FlexRender.Charts; + +/// +/// An axis-aligned plot rectangle in chart-local coordinates (origin at the chart box top-left). +/// +/// The left edge. +/// The top edge. +/// The right edge. +/// The bottom edge. +public readonly record struct PlotArea(float Left, float Top, float Right, float Bottom) +{ + /// Gets the plot width. + public float Width => Right - Left; + + /// Gets the plot height. + public float Height => Bottom - Top; +} + +/// +/// Pure, renderer-agnostic computation of the chart plot area by subtracting reserved bands +/// for the title, legend, and axis label gutters. +/// +public static class ChartLayout +{ + /// + /// Computes the inner plot rectangle. + /// + /// The chart box width. + /// The chart box height. + /// Whether a title band is reserved at the top. + /// The legend position (reserves a band on the corresponding side). + /// The left gutter reserved for y-axis labels. + /// The bottom gutter reserved for x-axis labels. + /// The title band height when is true. + /// The legend band size (height for top/bottom, width for left/right). + /// The computed , never inverted. + public static PlotArea ComputePlotArea( + float width, + float height, + bool hasTitle, + LegendPosition legend, + float axisGutterLeft, + float axisGutterBottom, + float titleHeight, + float legendExtent) + { + var left = axisGutterLeft; + var top = hasTitle ? titleHeight : 0f; + // Mirror the left gutter on the right so series geometry and edge labels are not clipped. + var right = width - axisGutterLeft; + var bottom = height - axisGutterBottom; + + switch (legend) + { + case LegendPosition.Top: + top += legendExtent; + break; + case LegendPosition.Bottom: + bottom -= legendExtent; + break; + case LegendPosition.Left: + left += legendExtent; + break; + case LegendPosition.Right: + right -= legendExtent; + break; + case LegendPosition.None: + default: + break; + } + + // Guard against inversion on tiny boxes. + right = Math.Max(right, left); + bottom = Math.Max(bottom, top); + + return new PlotArea(left, top, right, bottom); + } +} diff --git a/tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs b/tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs new file mode 100644 index 0000000..2d2e62d --- /dev/null +++ b/tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs @@ -0,0 +1,69 @@ +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for the renderer-agnostic plot-area subdivision math. +/// +public sealed class ChartLayoutMathTests +{ + [Fact] + public void ComputePlotArea_NoTitleNoLegend_LeavesAxisGutter() + { + var plot = ChartLayout.ComputePlotArea( + width: 600f, height: 300f, + hasTitle: false, legend: LegendPosition.None, + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + + Assert.Equal(40f, plot.Left); + Assert.Equal(0f, plot.Top); + Assert.Equal(600f - 40f, plot.Right); + Assert.Equal(300f - 24f, plot.Bottom); + } + + [Fact] + public void ComputePlotArea_WithTitle_ReservesTopBand() + { + var plot = ChartLayout.ComputePlotArea( + width: 600f, height: 300f, + hasTitle: true, legend: LegendPosition.None, + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + + Assert.Equal(24f, plot.Top); + } + + [Fact] + public void ComputePlotArea_BottomLegend_ReservesBottomBand() + { + var plot = ChartLayout.ComputePlotArea( + width: 600f, height: 300f, + hasTitle: false, legend: LegendPosition.Bottom, + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + + Assert.Equal(300f - 24f - 60f, plot.Bottom); + } + + [Fact] + public void ComputePlotArea_RightLegend_ReservesRightBand() + { + var plot = ChartLayout.ComputePlotArea( + width: 600f, height: 300f, + hasTitle: false, legend: LegendPosition.Right, + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + + Assert.Equal(600f - 40f - 60f, plot.Right); + } + + [Fact] + public void ComputePlotArea_DegenerateSize_DoesNotInvert() + { + var plot = ChartLayout.ComputePlotArea( + width: 30f, height: 20f, + hasTitle: true, legend: LegendPosition.Bottom, + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + + Assert.True(plot.Right >= plot.Left); + Assert.True(plot.Bottom >= plot.Top); + } +} From 4a18c20b9e71c0d25167d362ff1b89e1a8ac7716 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:11:11 +0400 Subject: [PATCH 046/119] feat(renderer): dispatch chart element and draw no-data placeholder --- .../Rendering/ChartRenderer.cs | 144 ++++++++++++++++++ .../Rendering/RenderingEngine.cs | 7 + .../Rendering/ChartRenderSmokeTests.cs | 93 +++++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs create mode 100644 tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs new file mode 100644 index 0000000..2d1b9dd --- /dev/null +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -0,0 +1,144 @@ +using System; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using SkiaSharp; + +namespace FlexRender.Rendering; + +/// +/// Draws instances to a SkiaSharp canvas. Computes the plot area +/// (minus title/legend/axis gutters), then draws grid, axes, series geometry, and legend using +/// the resolved theme and palette. Label text uses an supplied by the +/// caller; when null, labels are skipped but geometry still draws. +/// +internal static class ChartRenderer +{ + /// + /// Draws a chart into the given box. + /// + /// The canvas to draw on. + /// The chart element. + /// The chart box left edge. + /// The chart box top edge. + /// The chart box width. + /// The chart box height. + /// The typeface for labels, or null to skip labels. + /// Whether to anti-alias drawing. + /// Thrown when or is null. + internal static void Draw( + SKCanvas canvas, + ChartElement chart, + float x, + float y, + float width, + float height, + SKTypeface? typeface, + bool antialias) + { + ArgumentNullException.ThrowIfNull(canvas); + ArgumentNullException.ThrowIfNull(chart); + + if (width <= 0f || height <= 0f) + return; + + var theme = chart.Theme ?? ChartThemes.Default; + + canvas.Save(); + try + { + canvas.ClipRect(new SKRect(x, y, x + width, y + height)); + canvas.Translate(x, y); + + DrawChartBackground(canvas, theme, width, height, antialias); + + if (!HasAnyData(chart)) + { + DrawNoData(canvas, theme, width, height, typeface, antialias); + return; + } + + // Series geometry is added in subsequent tasks (bar/line/area/pie/donut). + DrawSeries(canvas, theme, width, height, antialias); + } + finally + { + canvas.Restore(); + } + } + + /// Returns whether any series has at least one data point. + private static bool HasAnyData(ChartElement chart) + { + foreach (var s in chart.Series) + { + if (s.Data.Count > 0) + return true; + } + return false; + } + + /// Fills the chart background using the theme color (skipped when transparent). + private static void DrawChartBackground(SKCanvas canvas, ChartTheme theme, float width, float height, bool antialias) + { + if (string.IsNullOrEmpty(theme.BackgroundColor)) + return; + + using var paint = new SKPaint + { + Color = ColorParser.Parse(theme.BackgroundColor), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + canvas.DrawRect(0f, 0f, width, height, paint); + } + + /// + /// Draws a centred "No data" placeholder: a light dashed border plus centered text + /// (or just the border when no typeface is available). + /// + private static void DrawNoData(SKCanvas canvas, ChartTheme theme, float width, float height, SKTypeface? typeface, bool antialias) + { + var inset = MathF.Min(width, height) * 0.08f; + var rect = new SKRect(inset, inset, width - inset, height - inset); + + using var dash = SKPathEffect.CreateDash([6f, 4f], 0f); + using var border = new SKPaint + { + Color = ColorParser.Parse(theme.AxisColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = 1.5f, + IsAntialias = antialias, + PathEffect = dash + }; + canvas.DrawRect(rect, border); + + if (typeface is null) + return; + + const string message = "No data"; + using var font = new SKFont(typeface, theme.LabelSize); + using var textPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + var textWidth = font.MeasureText(message); + var tx = (width - textWidth) / 2f; + var ty = (height + theme.LabelSize) / 2f; + canvas.DrawText(message, tx, ty, SKTextAlign.Left, font, textPaint); + } + + /// + /// Draws series geometry by chart type. Phase-2 bar geometry is added in Task 17; this + /// placeholder keeps the dispatch surface in place so smoke tests can run. + /// + private static void DrawSeries(SKCanvas canvas, ChartTheme theme, float width, float height, bool antialias) + { + // Filled in by subsequent tasks. Until then, draw a faint plot border so a chart with + // data is visibly non-blank in smoke tests. + using var border = new SKPaint + { + Color = ColorParser.Parse(theme.AxisColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = 1f, + IsAntialias = antialias + }; + canvas.DrawRect(0.5f, 0.5f, width - 1f, height - 1f, border); + } +} diff --git a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs index 1fd702f..73fc002 100644 --- a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs +++ b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs @@ -348,6 +348,13 @@ private void DrawElement( ShapeRenderer.DrawShapes(canvas, drawEl, x, y, width, height, renderOptions.Antialiasing); break; + case ChartElement chart: + ChartRenderer.Draw( + canvas, chart, x, y, width, height, + _fontManager?.GetTypeface("main"), + renderOptions.Antialiasing); + break; + case SeparatorElement separator: DrawSeparator(canvas, separator, x, y, width, height, renderOptions.Antialiasing); break; diff --git a/tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs b/tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs new file mode 100644 index 0000000..46ff0e6 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs @@ -0,0 +1,93 @@ +using System; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using FlexRender.TemplateEngine; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Smoke tests verifying charts are drawn (not merely laid out) by the Skia pipeline. +/// +public sealed class ChartRenderSmokeTests : IDisposable +{ + private readonly SkiaRenderer _renderer = new(); + private readonly TemplateParser _parser = new(); + + public void Dispose() + { + _renderer.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task EmptyChart_DrawsNoDataPlaceholderNotBlank() + { + const string yaml = """ + canvas: + width: 200 + height: 120 + background: "#ffffff" + layout: + - type: chart + chart-type: bar + width: 200 + height: 120 + series: [] + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected the 'no data' placeholder to draw something."); + } + + [Fact] + public async Task BarChart_WithData_DrawsColoredPixels() + { + const string yaml = """ + canvas: + width: 300 + height: 200 + background: "#ffffff" + layout: + - type: chart + chart-type: bar + width: 300 + height: 200 + categories: [A, B, C] + series: + - data: [10, 20, 15] + legend: none + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected the bar chart to draw something."); + } + + private static bool HasNonBackgroundPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y += 3) + for (var x = 0; x < bitmap.Width; x += 3) + { + var p = bitmap.GetPixel(x, y); + if (p.Red < 240 || p.Green < 240 || p.Blue < 240) + return true; + } + return false; + } + + private async Task Render(Template template, ObjectValue data) + { + var size = await _renderer.MeasureAsync(template, data); + var width = Math.Max((int)Math.Ceiling(size.Width), 1); + var height = Math.Max((int)Math.Ceiling(size.Height), 1); + var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); + await _renderer.Render(bitmap, template, data, default, default); + return bitmap; + } +} From 4d550071bafa19d85da1e6ab5dace1729ab5a396 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:13:50 +0400 Subject: [PATCH 047/119] feat(charts): add value-to-pixel mapper --- src/FlexRender.Core/Charts/ValueMapper.cs | 43 +++++++++++++++++++ .../Charts/ValueMapperTests.cs | 36 ++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/FlexRender.Core/Charts/ValueMapper.cs create mode 100644 tests/FlexRender.Tests/Charts/ValueMapperTests.cs diff --git a/src/FlexRender.Core/Charts/ValueMapper.cs b/src/FlexRender.Core/Charts/ValueMapper.cs new file mode 100644 index 0000000..44a43ce --- /dev/null +++ b/src/FlexRender.Core/Charts/ValueMapper.cs @@ -0,0 +1,43 @@ +namespace FlexRender.Charts; + +/// +/// Maps a numeric data value to a pixel Y within a plot band, where the scale minimum maps to +/// the plot bottom and the scale maximum maps to the plot top (screen Y grows downward). +/// +public readonly struct ValueMapper +{ + private readonly double _min; + private readonly double _max; + private readonly float _plotTop; + private readonly float _plotBottom; + + /// + /// Initializes a new instance of the struct. + /// + /// The scale minimum (maps to the plot bottom). + /// The scale maximum (maps to the plot top). + /// The plot top pixel Y. + /// The plot bottom pixel Y. + public ValueMapper(double min, double max, float plotTop, float plotBottom) + { + _min = min; + _max = max; + _plotTop = plotTop; + _plotBottom = plotBottom; + } + + /// + /// Maps a data value to its pixel Y within the plot band. + /// + /// The data value. + /// The pixel Y. Returns the plot bottom when the scale is degenerate. + public float MapY(double value) + { + var span = _max - _min; + if (span <= 0d) + return _plotBottom; + + var t = (value - _min) / span; + return _plotBottom - (float)(t * (_plotBottom - _plotTop)); + } +} diff --git a/tests/FlexRender.Tests/Charts/ValueMapperTests.cs b/tests/FlexRender.Tests/Charts/ValueMapperTests.cs new file mode 100644 index 0000000..752be79 --- /dev/null +++ b/tests/FlexRender.Tests/Charts/ValueMapperTests.cs @@ -0,0 +1,36 @@ +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for mapping data values to pixel positions within a plot band. +/// +public sealed class ValueMapperTests +{ + [Fact] + public void MapY_MinMapsToBottom_MaxMapsToTop() + { + // plot spans pixel Y 0 (top) .. 100 (bottom); scale 0..50. + var mapper = new ValueMapper(0d, 50d, plotTop: 0f, plotBottom: 100f); + + Assert.Equal(100f, mapper.MapY(0d), 3); + Assert.Equal(0f, mapper.MapY(50d), 3); + Assert.Equal(50f, mapper.MapY(25d), 3); + } + + [Fact] + public void MapY_ZeroBaseline_WhenScaleCrossesZero() + { + var mapper = new ValueMapper(-50d, 50d, plotTop: 0f, plotBottom: 100f); + Assert.Equal(50f, mapper.MapY(0d), 3); + } + + [Fact] + public void MapY_DegenerateScale_DoesNotDivideByZero() + { + var mapper = new ValueMapper(10d, 10d, plotTop: 0f, plotBottom: 100f); + var yy = mapper.MapY(10d); + Assert.True(float.IsFinite(yy)); + } +} From a9f3354ec74f5ffb8af9b6c10ff8d3d4ebc5aca3 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:15:47 +0400 Subject: [PATCH 048/119] feat(renderer): draw vertical bar charts with grid and axes --- .../Rendering/ChartRenderer.cs | 191 +++++++++++++++++- .../Rendering/ChartBarRenderTests.cs | 50 +++++ 2 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 tests/FlexRender.Tests/Rendering/ChartBarRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 2d1b9dd..5e9fe41 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -57,8 +57,7 @@ internal static void Draw( return; } - // Series geometry is added in subsequent tasks (bar/line/area/pie/donut). - DrawSeries(canvas, theme, width, height, antialias); + DrawSeries(canvas, chart, theme, width, height, typeface, antialias); } finally { @@ -125,20 +124,192 @@ private static void DrawNoData(SKCanvas canvas, ChartTheme theme, float width, f } /// - /// Draws series geometry by chart type. Phase-2 bar geometry is added in Task 17; this - /// placeholder keeps the dispatch surface in place so smoke tests can run. + /// Draws series geometry by chart type. Bar charts render grid, axes, and columns; other + /// chart types fall through to a faint plot border until added in later tasks. /// - private static void DrawSeries(SKCanvas canvas, ChartTheme theme, float width, float height, bool antialias) + private static void DrawSeries( + SKCanvas canvas, + ChartElement chart, + ChartTheme theme, + float width, + float height, + SKTypeface? typeface, + bool antialias) { - // Filled in by subsequent tasks. Until then, draw a faint plot border so a chart with - // data is visibly non-blank in smoke tests. - using var border = new SKPaint + switch (chart.ChartType) { - Color = ColorParser.Parse(theme.AxisColor), + case ChartType.Bar: + DrawBars(canvas, chart, theme, width, height, typeface, antialias); + break; + default: + // Other chart types are added in later tasks. + using (var border = new SKPaint + { + Color = ColorParser.Parse(theme.AxisColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = 1f, + IsAntialias = antialias + }) + { + canvas.DrawRect(0.5f, 0.5f, width - 1f, height - 1f, border); + } + break; + } + } + + /// Computes the combined data min/max across all series. + private static (double Min, double Max) DataBounds(ChartElement chart) + { + var min = double.MaxValue; + var max = double.MinValue; + foreach (var s in chart.Series) + { + foreach (var v in s.Data) + { + if (v < min) min = v; + if (v > max) max = v; + } + } + if (min == double.MaxValue) + return (0d, 1d); + return (min, max); + } + + /// Draws horizontal grid lines and y-axis tick labels for the given scale. + private static void DrawGridAndYAxis( + SKCanvas canvas, ChartTheme theme, in PlotArea plot, AxisScale scale, + SKTypeface? typeface, bool antialias) + { + var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom); + + using var grid = new SKPaint + { + Color = ColorParser.Parse(theme.GridColor), Style = SKPaintStyle.Stroke, StrokeWidth = 1f, IsAntialias = antialias }; - canvas.DrawRect(0.5f, 0.5f, width - 1f, height - 1f, border); + + SKFont? font = null; + SKPaint? labelPaint = null; + if (typeface is not null) + { + font = new SKFont(typeface, theme.LabelSize); + labelPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + } + + try + { + foreach (var tick in scale.Ticks) + { + var ty = mapper.MapY(tick); + canvas.DrawLine(plot.Left, ty, plot.Right, ty, grid); + + if (font is not null && labelPaint is not null) + { + var label = FormatTick(tick); + var tw = font.MeasureText(label); + canvas.DrawText(label, plot.Left - tw - 4f, ty + (theme.LabelSize / 3f), SKTextAlign.Left, font, labelPaint); + } + } + } + finally + { + font?.Dispose(); + labelPaint?.Dispose(); + } + } + + /// Draws x-axis category labels centred under each category slot. + private static void DrawCategoryLabels( + SKCanvas canvas, ChartElement chart, ChartTheme theme, in PlotArea plot, + SKTypeface? typeface, bool antialias) + { + if (typeface is null || chart.Categories.Count == 0) + return; + + using var font = new SKFont(typeface, theme.LabelSize); + using var paint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + + var slot = plot.Width / chart.Categories.Count; + for (var i = 0; i < chart.Categories.Count; i++) + { + var label = chart.Categories[i]; + var tw = font.MeasureText(label); + var cx = plot.Left + (slot * (i + 0.5f)); + canvas.DrawText(label, cx - (tw / 2f), plot.Bottom + theme.LabelSize + 2f, SKTextAlign.Left, font, paint); + } + } + + /// Draws a vertical or horizontal bar chart with grid and axis labels. + private static void DrawBars( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + var (dataMin, dataMax) = DataBounds(chart); + var scale = AxisScale.Compute(dataMin, dataMax, targetTicks: 5); + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var plot = ChartLayout.ComputePlotArea( + width, height, hasTitle, chart.Legend, + axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f); + + DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias); + + var palette = chart.Palette ?? ChartPalettes.Default; + var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom); + var zeroY = mapper.MapY(0d); + + var seriesCount = chart.Series.Count; + if (seriesCount == 0) + return; + + // Determine category count from the longest series. + var categoryCount = 0; + foreach (var s in chart.Series) + categoryCount = Math.Max(categoryCount, s.Data.Count); + if (categoryCount == 0) + return; + + var groupSlot = plot.Width / categoryCount; + var groupPadding = groupSlot * 0.15f; + var barAreaWidth = groupSlot - (2f * groupPadding); + var barWidth = barAreaWidth / seriesCount; + + for (var si = 0; si < seriesCount; si++) + { + var data = chart.Series[si].Data; + using var paint = new SKPaint + { + Color = ColorParser.Parse(palette.ColorAt(si)), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + + for (var ci = 0; ci < data.Count; ci++) + { + var value = data[ci]; + var valueY = mapper.MapY(value); + var barLeft = plot.Left + (groupSlot * ci) + groupPadding + (barWidth * si); + var top = Math.Min(valueY, zeroY); + var bottom = Math.Max(valueY, zeroY); + var rect = new SKRect(barLeft, top, barLeft + barWidth, bottom); + + if (theme.BarCornerRadius > 0f) + canvas.DrawRoundRect(rect, theme.BarCornerRadius, theme.BarCornerRadius, paint); + else + canvas.DrawRect(rect, paint); + } + } + + DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias); + } + + /// Formats a tick value compactly, trimming trailing zeros. + private static string FormatTick(double value) + { + if (value == Math.Floor(value) && Math.Abs(value) < 1e15) + return ((long)value).ToString(System.Globalization.CultureInfo.InvariantCulture); + return value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture); } } diff --git a/tests/FlexRender.Tests/Rendering/ChartBarRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartBarRenderTests.cs new file mode 100644 index 0000000..89ef33b --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartBarRenderTests.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies bar geometry is drawn with the palette color. +/// +public sealed class ChartBarRenderTests +{ + [Fact] + public void VerticalBars_DrawPaletteColoredColumns() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromInline("a", new[] { 10d, 20d, 30d }) + }) + { + Categories = new[] { "A", "B", "C" }, + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true); + + Assert.True(HasRedPixel(bitmap), "Expected red bar pixels."); + } + + private static bool HasRedPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y += 2) + for (var x = 0; x < bitmap.Width; x += 2) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 200 && p.Green < 80 && p.Blue < 80) + return true; + } + return false; + } +} From 19529f1e3a12f1ea842069864b45da06d8725fe5 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:19:08 +0400 Subject: [PATCH 049/119] feat(renderer): support horizontal bar charts --- .../Rendering/ChartRenderer.cs | 76 ++++++++++++++++--- .../ChartHorizontalBarRenderTests.cs | 51 +++++++++++++ 2 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 tests/FlexRender.Tests/Rendering/ChartHorizontalBarRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 5e9fe41..3ec82da 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -254,11 +254,7 @@ private static void DrawBars( width, height, hasTitle, chart.Legend, axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f); - DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias); - var palette = chart.Palette ?? ChartPalettes.Default; - var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom); - var zeroY = mapper.MapY(0d); var seriesCount = chart.Series.Count; if (seriesCount == 0) @@ -271,6 +267,26 @@ private static void DrawBars( if (categoryCount == 0) return; + if (chart.Horizontal) + { + DrawHorizontalBars(canvas, chart, theme, plot, scale, palette, seriesCount, categoryCount, antialias); + } + else + { + DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias); + DrawVerticalBars(canvas, chart, theme, plot, scale, palette, seriesCount, categoryCount, antialias); + DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias); + } + } + + /// Draws bars rising vertically from the zero baseline, grouped along the X axis by category. + private static void DrawVerticalBars( + SKCanvas canvas, ChartElement chart, ChartTheme theme, in PlotArea plot, AxisScale scale, + ChartPalette palette, int seriesCount, int categoryCount, bool antialias) + { + var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom); + var zeroY = mapper.MapY(0d); + var groupSlot = plot.Width / categoryCount; var groupPadding = groupSlot * 0.15f; var barAreaWidth = groupSlot - (2f * groupPadding); @@ -288,12 +304,9 @@ private static void DrawBars( for (var ci = 0; ci < data.Count; ci++) { - var value = data[ci]; - var valueY = mapper.MapY(value); + var valueY = mapper.MapY(data[ci]); var barLeft = plot.Left + (groupSlot * ci) + groupPadding + (barWidth * si); - var top = Math.Min(valueY, zeroY); - var bottom = Math.Max(valueY, zeroY); - var rect = new SKRect(barLeft, top, barLeft + barWidth, bottom); + var rect = new SKRect(barLeft, Math.Min(valueY, zeroY), barLeft + barWidth, Math.Max(valueY, zeroY)); if (theme.BarCornerRadius > 0f) canvas.DrawRoundRect(rect, theme.BarCornerRadius, theme.BarCornerRadius, paint); @@ -301,8 +314,51 @@ private static void DrawBars( canvas.DrawRect(rect, paint); } } + } + + /// Draws bars extending horizontally from the zero baseline, grouped along the Y axis by category. + private static void DrawHorizontalBars( + SKCanvas canvas, ChartElement chart, ChartTheme theme, in PlotArea plot, AxisScale scale, + ChartPalette palette, int seriesCount, int categoryCount, bool antialias) + { + // For horizontal bars the value axis is X: min -> plot.Left, max -> plot.Right. + // Copy the readonly plot fields into locals so the mapping closure can capture them. + var plotLeft = plot.Left; + var plotWidth = plot.Width; + var scaleMin = scale.Min; + var xSpan = scale.Max - scale.Min; + float MapX(double value) => xSpan <= 0d + ? plotLeft + : plotLeft + (float)(((value - scaleMin) / xSpan) * plotWidth); + var zeroX = MapX(0d); + + var groupSlot = plot.Height / categoryCount; + var groupPadding = groupSlot * 0.15f; + var barAreaHeight = groupSlot - (2f * groupPadding); + var barHeight = barAreaHeight / seriesCount; + + for (var si = 0; si < seriesCount; si++) + { + var data = chart.Series[si].Data; + using var paint = new SKPaint + { + Color = ColorParser.Parse(palette.ColorAt(si)), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; - DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias); + for (var ci = 0; ci < data.Count; ci++) + { + var valueX = MapX(data[ci]); + var barTop = plot.Top + (groupSlot * ci) + groupPadding + (barHeight * si); + var rect = new SKRect(Math.Min(valueX, zeroX), barTop, Math.Max(valueX, zeroX), barTop + barHeight); + + if (theme.BarCornerRadius > 0f) + canvas.DrawRoundRect(rect, theme.BarCornerRadius, theme.BarCornerRadius, paint); + else + canvas.DrawRect(rect, paint); + } + } } /// Formats a tick value compactly, trimming trailing zeros. diff --git a/tests/FlexRender.Tests/Rendering/ChartHorizontalBarRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartHorizontalBarRenderTests.cs new file mode 100644 index 0000000..da60e62 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartHorizontalBarRenderTests.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies horizontal bars extend along the X axis from the left baseline. +/// +public sealed class ChartHorizontalBarRenderTests +{ + [Fact] + public void HorizontalBars_DrawWiderForLargerValues() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromInline("a", new[] { 5d, 40d }) + }) + { + Categories = new[] { "A", "B" }, + Legend = LegendPosition.None, + Horizontal = true, + Palette = new ChartPalette(new[] { "#0000ff" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true); + + Assert.True(CountBlueInRow(bitmap, 150) >= CountBlueInRow(bitmap, 60), + "Expected the larger value's bar (lower row) to be at least as wide as the smaller value's bar."); + } + + private static int CountBlueInRow(SKBitmap bitmap, int y) + { + var count = 0; + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Blue > 200 && p.Red < 80 && p.Green < 80) + count++; + } + return count; + } +} From 42051d02bfd7f6f533ad6055300597fd63b0fd8c Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:20:40 +0400 Subject: [PATCH 050/119] feat(renderer): draw line and area charts --- .../Rendering/ChartRenderer.cs | 77 +++++++++++++++++++ .../Rendering/ChartLineAreaRenderTests.cs | 57 ++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 tests/FlexRender.Tests/Rendering/ChartLineAreaRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 3ec82da..1dd7b6e 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -141,6 +141,12 @@ private static void DrawSeries( case ChartType.Bar: DrawBars(canvas, chart, theme, width, height, typeface, antialias); break; + case ChartType.Line: + DrawLineOrArea(canvas, chart, theme, width, height, typeface, fillArea: false, antialias); + break; + case ChartType.Area: + DrawLineOrArea(canvas, chart, theme, width, height, typeface, fillArea: true, antialias); + break; default: // Other chart types are added in later tasks. using (var border = new SKPaint @@ -361,6 +367,77 @@ float MapX(double value) => xSpan <= 0d } } + /// Draws a line chart, optionally filling the area under each series. + private static void DrawLineOrArea( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool fillArea, bool antialias) + { + var (dataMin, dataMax) = DataBounds(chart); + var scale = AxisScale.Compute(dataMin, dataMax, targetTicks: 5); + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var plot = ChartLayout.ComputePlotArea( + width, height, hasTitle, chart.Legend, + axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f); + + DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias); + + var palette = chart.Palette ?? ChartPalettes.Default; + var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom); + var zeroY = mapper.MapY(0d); + + for (var si = 0; si < chart.Series.Count; si++) + { + var data = chart.Series[si].Data; + if (data.Count == 0) + continue; + + var color = ColorParser.Parse(palette.ColorAt(si)); + var step = data.Count > 1 ? plot.Width / (data.Count - 1) : 0f; + + float X(int i) => data.Count > 1 ? plot.Left + (step * i) : plot.Left + (plot.Width / 2f); + + using var linePath = new SKPath(); + linePath.MoveTo(X(0), mapper.MapY(data[0])); + for (var i = 1; i < data.Count; i++) + linePath.LineTo(X(i), mapper.MapY(data[i])); + + if (fillArea) + { + using var areaPath = new SKPath(); + areaPath.MoveTo(X(0), zeroY); + for (var i = 0; i < data.Count; i++) + areaPath.LineTo(X(i), mapper.MapY(data[i])); + areaPath.LineTo(X(data.Count - 1), zeroY); + areaPath.Close(); + + var fillColor = color.WithAlpha(70); + using var areaPaint = new SKPaint { Color = fillColor, Style = SKPaintStyle.Fill, IsAntialias = antialias }; + canvas.DrawPath(areaPath, areaPaint); + } + + using var linePaint = new SKPaint + { + Color = color, + Style = SKPaintStyle.Stroke, + StrokeWidth = theme.LineWidth, + IsAntialias = antialias, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round + }; + canvas.DrawPath(linePath, linePaint); + + if (chart.ShowPoints) + { + using var pointPaint = new SKPaint { Color = color, Style = SKPaintStyle.Fill, IsAntialias = antialias }; + for (var i = 0; i < data.Count; i++) + canvas.DrawCircle(X(i), mapper.MapY(data[i]), theme.LineWidth + 1f, pointPaint); + } + } + + DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias); + } + /// Formats a tick value compactly, trimming trailing zeros. private static string FormatTick(double value) { diff --git a/tests/FlexRender.Tests/Rendering/ChartLineAreaRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartLineAreaRenderTests.cs new file mode 100644 index 0000000..60f5e87 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartLineAreaRenderTests.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies line and area charts draw with the palette color. +/// +public sealed class ChartLineAreaRenderTests +{ + private static ChartElement Make(ChartType type) => new(type, new List + { + ChartSeries.FromInline("a", new[] { 10d, 30d, 20d, 40d }) + }) + { + Categories = new[] { "A", "B", "C", "D" }, + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#00aa00" }), + Theme = ChartThemes.Default + }; + + [Fact] + public void Line_DrawsGreenPixels() + { + var chart = Make(ChartType.Line); + Assert.True(Render(chart), "Expected green line pixels."); + } + + [Fact] + public void Area_DrawsGreenPixels() + { + var chart = Make(ChartType.Area); + Assert.True(Render(chart), "Expected green area pixels."); + } + + private static bool Render(ChartElement chart) + { + using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true); + + for (var y = 0; y < bitmap.Height; y += 2) + for (var x = 0; x < bitmap.Width; x += 2) + { + var p = bitmap.GetPixel(x, y); + if (p.Green > 120 && p.Red < 120 && p.Blue < 120) + return true; + } + return false; + } +} From 2a12e079261e2197e7567dd002a4c6dda8885843 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:23:17 +0400 Subject: [PATCH 051/119] feat(renderer): draw pie and donut charts --- .../Rendering/ChartRenderer.cs | 81 +++++++++++++++++++ .../Rendering/ChartPieRenderTests.cs | 63 +++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 tests/FlexRender.Tests/Rendering/ChartPieRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 1dd7b6e..edba3b3 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -147,6 +147,12 @@ private static void DrawSeries( case ChartType.Area: DrawLineOrArea(canvas, chart, theme, width, height, typeface, fillArea: true, antialias); break; + case ChartType.Pie: + DrawPie(canvas, chart, theme, width, height, typeface, isDonut: false, antialias); + break; + case ChartType.Donut: + DrawPie(canvas, chart, theme, width, height, typeface, isDonut: true, antialias); + break; default: // Other chart types are added in later tasks. using (var border = new SKPaint @@ -438,6 +444,81 @@ private static void DrawLineOrArea( DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias); } + /// + /// Draws a pie or donut chart from the first series' values. Slices are proportional to each + /// value's share of the total; donut leaves a hollow center. + /// + private static void DrawPie( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool isDonut, bool antialias) + { + if (chart.Series.Count == 0) + return; + + var data = chart.Series[0].Data; + var total = 0d; + foreach (var v in data) + { + if (v > 0d) + total += v; + } + if (total <= 0d) + { + DrawNoData(canvas, theme, width, height, typeface, antialias); + return; + } + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var top = hasTitle ? theme.TitleSize + 8f : 0f; + var legendReserve = chart.Legend == LegendPosition.Bottom ? 28f : 0f; + var availH = height - top - legendReserve; + var availW = width; + + var diameter = MathF.Min(availW, availH) * 0.85f; + var radius = diameter / 2f; + var cx = width / 2f; + var cy = top + (availH / 2f); + + var palette = chart.Palette ?? ChartPalettes.Default; + var bounds = new SKRect(cx - radius, cy - radius, cx + radius, cy + radius); + + var startAngle = -90f; // start at 12 o'clock + for (var i = 0; i < data.Count; i++) + { + if (data[i] <= 0d) + continue; + + var sweep = (float)(data[i] / total * 360d); + using var slice = new SKPath(); + slice.MoveTo(cx, cy); + slice.ArcTo(bounds, startAngle, sweep, forceMoveTo: false); + slice.Close(); + + using var paint = new SKPaint + { + Color = ColorParser.Parse(palette.ColorAt(i)), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + canvas.DrawPath(slice, paint); + + startAngle += sweep; + } + + if (isDonut) + { + using var hole = new SKPaint + { + Color = string.IsNullOrEmpty(theme.BackgroundColor) + ? SKColors.White + : ColorParser.Parse(theme.BackgroundColor), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + canvas.DrawCircle(cx, cy, radius * 0.55f, hole); + } + } + /// Formats a tick value compactly, trimming trailing zeros. private static string FormatTick(double value) { diff --git a/tests/FlexRender.Tests/Rendering/ChartPieRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartPieRenderTests.cs new file mode 100644 index 0000000..f95cf7b --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartPieRenderTests.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies pie and donut charts draw slices, and the donut leaves a hollow center. +/// +public sealed class ChartPieRenderTests +{ + private static ChartElement Make(ChartType type) => new(type, new List + { + ChartSeries.FromInline(null, new[] { 30d, 50d, 20d }) + }) + { + Categories = new[] { "A", "B", "C" }, + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000", "#00ff00", "#0000ff" }), + Theme = ChartThemes.Default, + PieLabels = PieLabelMode.None + }; + + [Fact] + public void Pie_DrawsColoredSlices() + { + using var bitmap = Render(Make(ChartType.Pie)); + Assert.True(HasColor(bitmap, redDominant: true), "Expected red slice pixels."); + } + + [Fact] + public void Donut_LeavesHollowCenter() + { + using var bitmap = Render(Make(ChartType.Donut)); + var center = bitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2); + Assert.True(center.Red > 230 && center.Green > 230 && center.Blue > 230, + $"Expected white donut center, got {center}."); + } + + private static SKBitmap Render(ChartElement chart) + { + var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: true); + return bitmap; + } + + private static bool HasColor(SKBitmap bitmap, bool redDominant) + { + for (var y = 0; y < bitmap.Height; y += 2) + for (var x = 0; x < bitmap.Width; x += 2) + { + var p = bitmap.GetPixel(x, y); + if (redDominant && p.Red > 200 && p.Green < 120 && p.Blue < 120) + return true; + } + return false; + } +} From da50bf64877fdef77b3a81ab742185e55d93eaad Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:24:45 +0400 Subject: [PATCH 052/119] feat(renderer): draw chart title and legend --- .../Rendering/ChartRenderer.cs | 81 +++++++++++++++++++ .../Rendering/ChartLegendTitleRenderTests.cs | 73 +++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/FlexRender.Tests/Rendering/ChartLegendTitleRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index edba3b3..40cad63 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using FlexRender.Charts; using FlexRender.Parsing.Ast; using SkiaSharp; @@ -58,6 +59,8 @@ internal static void Draw( } DrawSeries(canvas, chart, theme, width, height, typeface, antialias); + DrawTitle(canvas, chart, theme, width, typeface, antialias); + DrawLegend(canvas, chart, theme, width, height, typeface, antialias); } finally { @@ -519,6 +522,84 @@ private static void DrawPie( } } + /// Draws the centred chart title in the reserved top band, when present. + private static void DrawTitle( + SKCanvas canvas, ChartElement chart, ChartTheme theme, float width, SKTypeface? typeface, bool antialias) + { + if (typeface is null || string.IsNullOrEmpty(chart.Title)) + return; + + using var font = new SKFont(typeface, theme.TitleSize); + using var paint = new SKPaint { Color = ColorParser.Parse(theme.TitleColor), IsAntialias = antialias }; + var tw = font.MeasureText(chart.Title); + canvas.DrawText(chart.Title, (width - tw) / 2f, theme.TitleSize + 2f, SKTextAlign.Left, font, paint); + } + + /// + /// Draws a simple legend (colored swatch + series label) for each labeled series. + /// Supports the bottom legend band; other positions reserve space but use the same row layout. + /// + private static void DrawLegend( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + if (typeface is null || chart.Legend == LegendPosition.None) + return; + + using var font = new SKFont(typeface, theme.LabelSize); + using var textPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + + var palette = chart.Palette ?? ChartPalettes.Default; + const float swatch = 10f; + const float gap = 6f; + const float itemGap = 16f; + + // Build entries: for pie/donut use categories, otherwise use series labels. + var labels = new List(); + if (chart.ChartType is ChartType.Pie or ChartType.Donut) + { + foreach (var c in chart.Categories) + labels.Add(c); + } + else + { + for (var i = 0; i < chart.Series.Count; i++) + labels.Add(chart.Series[i].Label ?? $"Series {i + 1}"); + } + + if (labels.Count == 0) + return; + + // Measure total width for centring along the bottom. + var totalWidth = 0f; + foreach (var label in labels) + totalWidth += swatch + gap + font.MeasureText(label) + itemGap; + totalWidth -= itemGap; + + var startX = (width - totalWidth) / 2f; + var rowY = chart.Legend == LegendPosition.Top + ? theme.LabelSize + 4f + : height - (theme.LabelSize / 2f) - 4f; + + var x = startX; + for (var i = 0; i < labels.Count; i++) + { + using (var swatchPaint = new SKPaint + { + Color = ColorParser.Parse(palette.ColorAt(i)), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }) + { + canvas.DrawRect(x, rowY - swatch, swatch, swatch, swatchPaint); + } + + x += swatch + gap; + canvas.DrawText(labels[i], x, rowY, SKTextAlign.Left, font, textPaint); + x += font.MeasureText(labels[i]) + itemGap; + } + } + /// Formats a tick value compactly, trimming trailing zeros. private static string FormatTick(double value) { diff --git a/tests/FlexRender.Tests/Rendering/ChartLegendTitleRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartLegendTitleRenderTests.cs new file mode 100644 index 0000000..a8de8d1 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartLegendTitleRenderTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies the title band and bottom legend render text when a typeface is available. +/// +public sealed class ChartLegendTitleRenderTests +{ + [Fact] + public void TitleAndLegend_DrawTextInReservedBands() + { + using var typeface = LoadInter(); + Assert.NotNull(typeface); + + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromInline("Series One", new[] { 10d, 20d, 30d }) + }) + { + Categories = new[] { "A", "B", "C" }, + Legend = LegendPosition.Bottom, + Title = "Revenue", + Palette = new ChartPalette(new[] { "#3366cc" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(320, 240, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 320f, 240f, typeface, antialias: true); + + Assert.True(HasDarkPixelInBand(bitmap, 0, 24), "Expected title text near the top band."); + Assert.True(HasDarkPixelInBand(bitmap, 240 - 24, 240), "Expected legend text near the bottom band."); + } + + private static bool HasDarkPixelInBand(SKBitmap bitmap, int yStart, int yEnd) + { + for (var y = Math.Max(0, yStart); y < Math.Min(bitmap.Height, yEnd); y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red < 150 && p.Green < 150 && p.Blue < 150) + return true; + } + return false; + } + + private static SKTypeface? LoadInter() + { + var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; + var current = asmDir; + while (!string.IsNullOrEmpty(current)) + { + if (Directory.GetFiles(current, "*.csproj").Length > 0) + { + var fontPath = Path.Combine(current, "Snapshots", "Fonts", "Inter-Regular.ttf"); + return File.Exists(fontPath) ? SKTypeface.FromFile(fontPath) : null; + } + current = Directory.GetParent(current)?.FullName; + } + return null; + } +} From 618d3ae17c4d99458a2cdeb81c7896886373fe20 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:28:17 +0400 Subject: [PATCH 053/119] test(charts): add golden snapshots for chart types and themes --- .../Snapshots/ChartSnapshotTests.cs | 162 ++++++++++++++++++ .../Snapshots/golden/chart_area_light.png | Bin 0 -> 12413 bytes .../golden/chart_bar_horizontal_dark.png | Bin 0 -> 2361 bytes .../Snapshots/golden/chart_bar_light.png | Bin 0 -> 8073 bytes .../Snapshots/golden/chart_donut_dark.png | Bin 0 -> 13095 bytes .../Snapshots/golden/chart_line_minimal.png | Bin 0 -> 16537 bytes .../Snapshots/golden/chart_no_data.png | Bin 0 -> 2021 bytes .../Snapshots/golden/chart_pie_light.png | Bin 0 -> 11494 bytes 8 files changed, 162 insertions(+) create mode 100644 tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_area_light.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_bar_horizontal_dark.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_bar_light.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_donut_dark.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_line_minimal.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_no_data.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_pie_light.png diff --git a/tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs b/tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs new file mode 100644 index 0000000..06da14d --- /dev/null +++ b/tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs @@ -0,0 +1,162 @@ +using FlexRender; +using Xunit; + +namespace FlexRender.Tests.Snapshots; + +/// +/// Golden-image snapshot tests for charts (types × themes). +/// +public sealed class ChartSnapshotTests : SnapshotTestBase +{ + [Fact] + public async Task BarChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 600 + background: "#ffffff" + layout: + - type: chart + chart-type: bar + width: 600 + height: 320 + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: [12, 30, 22, 48] + title: Revenue + legend: bottom + palette: ocean + """); + await AssertSnapshot("chart_bar_light", template, new ObjectValue()); + } + + [Fact] + public async Task BarChart_HorizontalDark() + { + var template = Parser.Parse(""" + canvas: + width: 600 + background: "#1e1e1e" + layout: + - type: chart + chart-type: bar + width: 600 + height: 320 + horizontal: true + categories: [A, B, C, D] + series: + - data: [5, 40, 25, 60] + theme: dark + legend: none + palette: vivid + """); + await AssertSnapshot("chart_bar_horizontal_dark", template, new ObjectValue()); + } + + [Fact] + public async Task LineChart_Minimal() + { + var template = Parser.Parse(""" + canvas: + width: 600 + background: "#ffffff" + layout: + - type: chart + chart-type: line + width: 600 + height: 300 + categories: [Mon, Tue, Wed, Thu, Fri] + series: + - label: Visitors + data: [120, 200, 150, 280, 240] + - label: Signups + data: [20, 45, 30, 60, 50] + theme: minimal + points: true + legend: bottom + """); + await AssertSnapshot("chart_line_minimal", template, new ObjectValue()); + } + + [Fact] + public async Task AreaChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 600 + background: "#ffffff" + layout: + - type: chart + chart-type: area + width: 600 + height: 300 + categories: [Jan, Feb, Mar, Apr] + series: + - data: [30, 60, 45, 80] + legend: none + palette: forest + """); + await AssertSnapshot("chart_area_light", template, new ObjectValue()); + } + + [Fact] + public async Task PieChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 400 + background: "#ffffff" + layout: + - type: chart + chart-type: pie + width: 400 + height: 360 + categories: [Direct, Social, Search] + series: + - data: [30, 50, 20] + legend: bottom + palette: sunset + """); + await AssertSnapshot("chart_pie_light", template, new ObjectValue()); + } + + [Fact] + public async Task DonutChart_Dark() + { + var template = Parser.Parse(""" + canvas: + width: 400 + background: "#1e1e1e" + layout: + - type: chart + chart-type: donut + width: 400 + height: 360 + categories: [A, B, C, D] + series: + - data: [10, 20, 30, 40] + theme: dark + legend: bottom + palette: ocean + """); + await AssertSnapshot("chart_donut_dark", template, new ObjectValue()); + } + + [Fact] + public async Task EmptyChart_NoDataPlaceholder() + { + var template = Parser.Parse(""" + canvas: + width: 300 + background: "#ffffff" + layout: + - type: chart + chart-type: bar + width: 300 + height: 180 + series: [] + """); + await AssertSnapshot("chart_no_data", template, new ObjectValue()); + } +} diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_area_light.png b/tests/FlexRender.Tests/Snapshots/golden/chart_area_light.png new file mode 100644 index 0000000000000000000000000000000000000000..ca6ea1b3329dd5f828caf5aafc474c11ab74f6fa GIT binary patch literal 12413 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 aXLe}lb5S?A3I6j1Lsd~jp-j#q;Qs(XC!Q<- literal 0 HcmV?d00001 diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_line_minimal.png b/tests/FlexRender.Tests/Snapshots/golden/chart_line_minimal.png new file mode 100644 index 0000000000000000000000000000000000000000..a75a8b0910950466cf67126d87ad93141f0b99b9 GIT binary patch literal 16537 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 C)&SxF literal 0 HcmV?d00001 diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_no_data.png b/tests/FlexRender.Tests/Snapshots/golden/chart_no_data.png new file mode 100644 index 0000000000000000000000000000000000000000..64a6c3edceb9fa153b3e82b8b6e0ef9e5178a574 GIT binary patch literal 2021 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 Date: Sat, 13 Jun 2026 21:33:17 +0400 Subject: [PATCH 054/119] docs: document chart element in llms.txt and llms-full.txt --- llms-full.txt | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++- llms.txt | 5 +++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 84cc616..e68253b 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -623,7 +623,7 @@ layout: ## Flex-Item Properties -All leaf elements (text, image, qr, barcode, separator, rect, circle, ellipse, draw) and flex containers (when nested) have these flex-item properties: +All leaf elements (text, image, qr, barcode, separator, rect, circle, ellipse, draw, chart) and flex containers (when nested) have these flex-item properties: | Property | Type | Default | Description | |----------|------|---------|-------------| @@ -961,6 +961,68 @@ Example: `d: "M 0 0 L 100 50 Q 150 0 200 50 Z"` - path: {d: "M 0 0 L 100 50 Q 150 0 200 50 Z", fill: "#2ecc71"} ``` +## Element Type: chart (ChartElement) + +A declarative chart element that renders bar, line, area, pie, and donut charts. The chart paints into its `width` by `height` box and participates in flex layout like any other element. Both `width` and `height` are required. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| chart-type | string | `bar` | Chart kind: `bar`, `line`, `area`, `pie`, `donut` | +| width | number | none | Chart width in pixels (**required**) | +| height | number | none | Chart height in pixels (**required**) | +| categories | string[] | `[]` | X-axis category labels (bar/line/area) or slice labels (pie/donut) | +| series | Series[] | `[]` | List of series, each `{ label?, data }` (see below) | +| palette | string OR string[] | theme default | Named palette (`default`, `ocean`, `sunset`, `forest`, `mono`, `vivid`) OR an explicit color list `["#hex", ...]` | +| theme | string | template theme | Per-element theme override: `light`, `dark`, `minimal` | +| legend | string | `bottom` | Legend placement: `top`, `bottom`, `left`, `right`, `none` | +| title | string | none | Optional chart title | +| horizontal | bool | `false` | **Bar only.** Render bars horizontally | +| stacked | bool | `false` | **Bar only.** Stack series on top of each other | +| smooth | bool | `false` | **Line/area only.** Smooth (curved) lines instead of straight segments | +| points | bool | `false` | **Line/area only.** Draw point markers at each data value | +| labels | string | `percent` | **Pie/donut only.** Slice label content: `percent`, `value`, `none` | + +### Series and Data Binding + +Each entry in `series` is an object with an optional `label` (used in the legend) and a required `data` value. `data` is either an inline array of numbers, **or** a `{{ expression }}` string that resolves to a number array from the render data context -- the same binding mechanism used by `table` rows. This lets chart data come directly from the data file: + +```yaml +series: + - label: "2024" + data: [12, 30, 22, 48] # inline literal numbers + - label: "2025" + data: "{{ sales }}" # resolves to a number array from data +``` + +### Palettes and Themes + +The `palette` selects series colors. Use one of the named palettes (`default`, `ocean`, `sunset`, `forest`, `mono`, `vivid`) or supply an explicit list of hex colors that is applied to series/slices in order. The `theme` (`light`, `dark`, `minimal`) controls the chart's background, axis, grid, and text colors; it overrides the template-level theme for this element only. + +### "No Data" Behavior + +A chart whose `series` is empty or missing -- or whose series contain no data points -- renders a centered **"no data"** placeholder inside its box. This is never treated as an error, so charts bound to optional or empty data sources degrade gracefully. + +### Resource Limits + +Charts are bounded by `ResourceLimits.MaxSeriesPerChart` (default 50) and `ResourceLimits.MaxDataPointsPerSeries` (default 10000). + +```yaml +- type: chart + chart-type: bar + width: 600 + height: 300 + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: "{{ sales }}" + - label: "2025" + data: [12, 30, 22, 48] + palette: ocean + legend: bottom + title: "Revenue" + theme: dark +``` + ## Element Type: svg (SvgElement) Renders SVG vector graphics. Supports external files (`src`) or inline markup (`content`). These are mutually exclusive. @@ -1545,6 +1607,8 @@ The `ResourceLimits` class centralizes all security limits. All properties valid | MaxImageSize | int | 10,485,760 (10 MB) | Maximum allowed image size in bytes | | MaxFlexLines | int | 1,000 | Maximum flex lines when wrapping | | MaxShapesPerDraw | int | 1,000 | Maximum shapes in a single `draw` element | +| MaxSeriesPerChart | int | 50 | Maximum series in a single `chart` element | +| MaxDataPointsPerSeries | int | 10,000 | Maximum data points per chart series | ### Direct Usage diff --git a/llms.txt b/llms.txt index 128e8d6..068aa0b 100644 --- a/llms.txt +++ b/llms.txt @@ -74,7 +74,7 @@ Templates can be parsed once and cached for reuse with different data. ## Element Types -Fifteen element types, each a sealed class extending `TemplateElement`: +Sixteen element types, each a sealed class extending `TemplateElement`: | Type | Key Properties | |------|---------------| @@ -89,6 +89,7 @@ Fifteen element types, each a sealed class extending `TemplateElement`: | **circle** | fill (hex color OR gradient object), stroke, stroke-width, opacity, size (shorthand setting width & height = diameter) -- flex box drawn as a circle | | **ellipse** | fill (hex color OR gradient object), stroke, stroke-width, opacity -- flex box drawn as an ellipse filling its width/height | | **draw** | shapes (ordered list painted in list order); shape kinds: line, polyline, rect, circle, path (abs commands M/L/Q/C/Z); absolute coords relative to element top-left; capped by `MaxShapesPerDraw` (default 1000). Box shapes accept gradient fill objects | +| **chart** | chart-type (bar/line/area/pie/donut, default bar), width/height (required), categories (x-axis/slice labels), series (list of {label?, data}; data = inline number array OR `{{ expr }}` resolving to a number array), palette (named default/ocean/sunset/forest/mono/vivid OR explicit `["#hex",...]`), theme (light/dark/minimal override), legend (top/bottom/left/right/none, default bottom), title; bar only: horizontal, stacked; line/area: smooth, points; pie/donut: labels (percent/value/none). Empty/missing series renders a "no data" placeholder, never an error. Capped by `MaxSeriesPerChart` (50) and `MaxDataPointsPerSeries` (10000) | | **table** | array, as, columns (key/label/width/grow/align/format), rows, headerFont/headerFontWeight/headerFontStyle/headerFontFamily/headerColor/headerSize/headerBackground -- expands to flex tree | | **each** | array, as, children -- iteration over data arrays | | **if** | condition, equals/notEquals, then, elseIf, else -- conditional rendering | @@ -416,6 +417,8 @@ The `ResourceLimits` class centralizes all security limits. Defaults are safe-by | MaxImageSize | 10 MB | Image loading | | MaxFlexLines | 1000 | Maximum flex lines when wrapping | | MaxShapesPerDraw | 1000 | Maximum shapes in a single `draw` element | +| MaxSeriesPerChart | 50 | Maximum series in a single `chart` element | +| MaxDataPointsPerSeries | 10000 | Maximum data points per chart series | Configure via builder: `builder.WithLimits(limits => { limits.MaxRenderDepth = 200; })` From 606e0fd7ea365bb962ae2fc1f1a5af60469b0001 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:35:34 +0400 Subject: [PATCH 055/119] docs(wiki): document chart element reference and visual examples --- docs/wiki/Element-Reference.md | 60 ++++++++- docs/wiki/Visual-Reference.md | 114 ++++++++++++++++++ examples/visual-docs/charts/area-light.yaml | 21 ++++ examples/visual-docs/charts/bar-light.yaml | 23 ++++ examples/visual-docs/charts/donut-dark.yaml | 22 ++++ examples/visual-docs/charts/line-minimal.yaml | 25 ++++ examples/visual-docs/charts/pie-light.yaml | 21 ++++ .../visual-docs/output/chart-area-light.png | Bin 0 -> 12413 bytes .../visual-docs/output/chart-bar-light.png | Bin 0 -> 8073 bytes .../visual-docs/output/chart-donut-dark.png | Bin 0 -> 13095 bytes .../visual-docs/output/chart-line-minimal.png | Bin 0 -> 16537 bytes .../visual-docs/output/chart-pie-light.png | Bin 0 -> 11494 bytes 12 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 examples/visual-docs/charts/area-light.yaml create mode 100644 examples/visual-docs/charts/bar-light.yaml create mode 100644 examples/visual-docs/charts/donut-dark.yaml create mode 100644 examples/visual-docs/charts/line-minimal.yaml create mode 100644 examples/visual-docs/charts/pie-light.yaml create mode 100644 examples/visual-docs/output/chart-area-light.png create mode 100644 examples/visual-docs/output/chart-bar-light.png create mode 100644 examples/visual-docs/output/chart-donut-dark.png create mode 100644 examples/visual-docs/output/chart-line-minimal.png create mode 100644 examples/visual-docs/output/chart-pie-light.png diff --git a/docs/wiki/Element-Reference.md b/docs/wiki/Element-Reference.md index 3e1f2d3..b741449 100644 --- a/docs/wiki/Element-Reference.md +++ b/docs/wiki/Element-Reference.md @@ -12,7 +12,7 @@ For rendering options (antialiasing, format settings), see [[Render-Options]]. ## Common Properties (TemplateElement) -All 15 element types (`flex`, `text`, `image`, `svg`, `qr`, `barcode`, `separator`, `rect`, `circle`, `ellipse`, `draw`, `table`, `content`, `each`, `if`) inherit these properties from the base `TemplateElement` class. You can use any of them on any element. +All 16 element types (`flex`, `text`, `image`, `svg`, `qr`, `barcode`, `separator`, `rect`, `circle`, `ellipse`, `draw`, `chart`, `table`, `content`, `each`, `if`) inherit these properties from the base `TemplateElement` class. You can use any of them on any element. > **Expression support:** All properties on all element types accept `{{expressions}}`. This includes typed properties like `opacity` (float), `grow`/`shrink` (float), `order` (int), `wrap` (bool on text, FlexWrap on flex), and enum properties like `display`, `position`, `align`. See [[Template-Expressions]] for details. @@ -1457,6 +1457,64 @@ Z # close path --- +## chart + +A declarative chart element that renders `bar`, `line`, `area`, `pie`, and `donut` charts into a fixed `width` by `height` box. It participates in flex layout like any other element. Series data may be supplied inline or bound from the data context with `{{expressions}}` -- the same binding used by `table` rows -- so charts can be driven directly by a data file. A chart with no series (or empty data) renders a centered "no data" placeholder instead of failing. + +### Minimal Example + +```yaml +- type: chart + chart-type: bar + width: 600 + height: 320 + categories: [Q1, Q2, Q3, Q4] + series: + - data: [12, 30, 22, 48] +``` + +### Properties + +| Property | YAML Name | Type | Default | Valid Values | Required | Description | +|----------|-----------|------|---------|--------------|----------|-------------| +| ChartType | `chart-type` | string | `bar` | bar, line, area, pie, donut | No | Chart kind. | +| Width | `width` | number | none | px | Yes | Chart width in pixels. | +| Height | `height` | number | none | px | Yes | Chart height in pixels. | +| Categories | `categories` | list | `[]` | List of strings | No | X-axis category labels (bar/line/area) or slice labels (pie/donut). | +| Series | `series` | list | `[]` | List of `{ label?, data }` | No | Each series has an optional `label` and a `data` value that is an inline number array OR a `{{ expr }}` resolving to a number array. | +| Palette | `palette` | string or list | theme default | `default`, `ocean`, `sunset`, `forest`, `mono`, `vivid`, or `["#hex", ...]` | No | Named palette or an explicit color list applied to series/slices in order. | +| Theme | `theme` | string | template theme | light, dark, minimal | No | Per-element theme override (background, axis, grid, text colors). | +| Legend | `legend` | string | `bottom` | top, bottom, left, right, none | No | Legend placement. | +| Title | `title` | string | none | Any string | No | Optional chart title. | +| Horizontal | `horizontal` | bool | `false` | true, false | No | **Bar only.** Render bars horizontally. | +| Stacked | `stacked` | bool | `false` | true, false | No | **Bar only.** Stack series on top of each other. | +| Smooth | `smooth` | bool | `false` | true, false | No | **Line/area only.** Smooth (curved) lines. | +| Points | `points` | bool | `false` | true, false | No | **Line/area only.** Draw point markers at each value. | +| Labels | `labels` | string | `percent` | percent, value, none | No | **Pie/donut only.** Slice label content. | + +> **Resource limits:** charts are bounded by `ResourceLimits.MaxSeriesPerChart` (default `50`) and `ResourceLimits.MaxDataPointsPerSeries` (default `10000`). + +### Data Binding Example + +```yaml +- type: chart + chart-type: line + width: 600 + height: 300 + categories: [Mon, Tue, Wed, Thu, Fri] + series: + - label: Visitors + data: "{{ visitors }}" # resolves to a number array from data + - label: Signups + data: [20, 45, 30, 60, 50] + points: true + palette: ocean + legend: bottom + title: "Weekly Traffic" +``` + +--- + ## table Renders tabular data with configurable columns, optional header row, and support for both dynamic (data-driven) and static rows. During template expansion, the `table` element is expanded into a tree of `flex` and `text` elements -- no changes to the layout or rendering engines are needed. diff --git a/docs/wiki/Visual-Reference.md b/docs/wiki/Visual-Reference.md index abc66b2..e7f2c27 100644 --- a/docs/wiki/Visual-Reference.md +++ b/docs/wiki/Visual-Reference.md @@ -33,6 +33,7 @@ A comprehensive visual guide with rendered examples for all FlexRender propertie - [Shapes (rect, circle, ellipse)](#shapes-rect-circle-ellipse) - [Gradient Fills](#gradient-fills) - [draw](#draw) + - [Charts](#charts) - [See Also](#see-also) --- @@ -734,6 +735,119 @@ layout: --- +### Charts + +The `chart` element renders `bar`, `line`, `area`, `pie`, and `donut` charts into a fixed `width` by `height` box. Series `data` is either an inline number array or a `{{ expression }}` bound from the data context (like `table` rows). The `palette` (named or explicit color list) and `theme` (`light`, `dark`, `minimal`) control colors. An empty/missing `series` renders a "no data" placeholder rather than an error. + +#### Bar + +```yaml +canvas: + width: 600 + background: "#ffffff" +layout: + - type: chart + chart-type: bar + width: 600 + height: 320 + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: [12, 30, 22, 48] + title: Revenue + legend: bottom + palette: ocean +``` + +![Bar chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-bar-light.png) + +#### Line + +```yaml +canvas: + width: 600 + background: "#ffffff" +layout: + - type: chart + chart-type: line + width: 600 + height: 300 + categories: [Mon, Tue, Wed, Thu, Fri] + series: + - label: Visitors + data: [120, 200, 150, 280, 240] + - label: Signups + data: [20, 45, 30, 60, 50] + theme: minimal + points: true + legend: bottom +``` + +![Line chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-line-minimal.png) + +#### Area + +```yaml +canvas: + width: 600 + background: "#ffffff" +layout: + - type: chart + chart-type: area + width: 600 + height: 300 + categories: [Jan, Feb, Mar, Apr] + series: + - data: [30, 60, 45, 80] + legend: none + palette: forest +``` + +![Area chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-area-light.png) + +#### Pie + +```yaml +canvas: + width: 400 + background: "#ffffff" +layout: + - type: chart + chart-type: pie + width: 400 + height: 360 + categories: [Direct, Social, Search] + series: + - data: [30, 50, 20] + legend: bottom + palette: sunset +``` + +![Pie chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-pie-light.png) + +#### Donut + +```yaml +canvas: + width: 400 + background: "#1e1e1e" +layout: + - type: chart + chart-type: donut + width: 400 + height: 360 + categories: [A, B, C, D] + series: + - data: [10, 20, 30, 40] + theme: dark + legend: bottom + palette: ocean +``` + +![Donut chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-donut-dark.png) + +--- + ## Renderer Backend Comparison FlexRender supports three rendering backends. The same template produces visually similar output across all backends, with some backend-specific features: diff --git a/examples/visual-docs/charts/area-light.yaml b/examples/visual-docs/charts/area-light.yaml new file mode 100644 index 0000000..36eb7eb --- /dev/null +++ b/examples/visual-docs/charts/area-light.yaml @@ -0,0 +1,21 @@ +template: + name: "chart-area-light" + version: 1 + +fonts: + default: "../../assets/fonts/Inter-Regular.ttf" + +canvas: + width: 600 + background: "#ffffff" + +layout: + - type: chart + chart-type: area + width: 600 + height: 300 + categories: [Jan, Feb, Mar, Apr] + series: + - data: [30, 60, 45, 80] + legend: none + palette: forest diff --git a/examples/visual-docs/charts/bar-light.yaml b/examples/visual-docs/charts/bar-light.yaml new file mode 100644 index 0000000..ab37dde --- /dev/null +++ b/examples/visual-docs/charts/bar-light.yaml @@ -0,0 +1,23 @@ +template: + name: "chart-bar-light" + version: 1 + +fonts: + default: "../../assets/fonts/Inter-Regular.ttf" + +canvas: + width: 600 + background: "#ffffff" + +layout: + - type: chart + chart-type: bar + width: 600 + height: 320 + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: [12, 30, 22, 48] + title: Revenue + legend: bottom + palette: ocean diff --git a/examples/visual-docs/charts/donut-dark.yaml b/examples/visual-docs/charts/donut-dark.yaml new file mode 100644 index 0000000..9607359 --- /dev/null +++ b/examples/visual-docs/charts/donut-dark.yaml @@ -0,0 +1,22 @@ +template: + name: "chart-donut-dark" + version: 1 + +fonts: + default: "../../assets/fonts/Inter-Regular.ttf" + +canvas: + width: 400 + background: "#1e1e1e" + +layout: + - type: chart + chart-type: donut + width: 400 + height: 360 + categories: [A, B, C, D] + series: + - data: [10, 20, 30, 40] + theme: dark + legend: bottom + palette: ocean diff --git a/examples/visual-docs/charts/line-minimal.yaml b/examples/visual-docs/charts/line-minimal.yaml new file mode 100644 index 0000000..eaf099b --- /dev/null +++ b/examples/visual-docs/charts/line-minimal.yaml @@ -0,0 +1,25 @@ +template: + name: "chart-line-minimal" + version: 1 + +fonts: + default: "../../assets/fonts/Inter-Regular.ttf" + +canvas: + width: 600 + background: "#ffffff" + +layout: + - type: chart + chart-type: line + width: 600 + height: 300 + categories: [Mon, Tue, Wed, Thu, Fri] + series: + - label: Visitors + data: [120, 200, 150, 280, 240] + - label: Signups + data: [20, 45, 30, 60, 50] + theme: minimal + points: true + legend: bottom diff --git a/examples/visual-docs/charts/pie-light.yaml b/examples/visual-docs/charts/pie-light.yaml new file mode 100644 index 0000000..699854e --- /dev/null +++ b/examples/visual-docs/charts/pie-light.yaml @@ -0,0 +1,21 @@ +template: + name: "chart-pie-light" + version: 1 + +fonts: + default: "../../assets/fonts/Inter-Regular.ttf" + +canvas: + width: 400 + background: "#ffffff" + +layout: + - type: chart + chart-type: pie + width: 400 + height: 360 + categories: [Direct, Social, Search] + series: + - data: [30, 50, 20] + legend: bottom + palette: sunset diff --git a/examples/visual-docs/output/chart-area-light.png b/examples/visual-docs/output/chart-area-light.png new file mode 100644 index 0000000000000000000000000000000000000000..ca6ea1b3329dd5f828caf5aafc474c11ab74f6fa GIT binary patch literal 12413 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 aXLe}lb5S?A3I6j1Lsd~jp-j#q;Qs(XC!Q<- literal 0 HcmV?d00001 diff --git a/examples/visual-docs/output/chart-line-minimal.png b/examples/visual-docs/output/chart-line-minimal.png new file mode 100644 index 0000000000000000000000000000000000000000..a75a8b0910950466cf67126d87ad93141f0b99b9 GIT binary patch literal 16537 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 C)&SxF literal 0 HcmV?d00001 diff --git a/examples/visual-docs/output/chart-pie-light.png b/examples/visual-docs/output/chart-pie-light.png new file mode 100644 index 0000000000000000000000000000000000000000..d4757f8af328d4bc41efafbc966d54479dab3bb6 GIT binary patch literal 11494 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 Date: Sat, 13 Jun 2026 21:36:17 +0400 Subject: [PATCH 056/119] docs(playground): add chart to schema and autocomplete --- .../wwwroot/schemas/flexrender-template.json | 91 ++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json b/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json index a5b7301..2315aae 100644 --- a/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json +++ b/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json @@ -199,13 +199,20 @@ "required": ["type"] }, "then": { "$ref": "#/definitions/drawElement" } + }, + { + "if": { + "properties": { "type": { "const": "chart" } }, + "required": ["type"] + }, + "then": { "$ref": "#/definitions/chartElement" } } ], "properties": { "type": { "description": "The element type.", "type": "string", - "enum": ["text", "flex", "image", "qr", "barcode", "separator", "svg", "table", "each", "if", "content", "rect", "circle", "ellipse", "draw"] + "enum": ["text", "flex", "image", "qr", "barcode", "separator", "svg", "table", "each", "if", "content", "rect", "circle", "ellipse", "draw", "chart"] } } }, @@ -863,6 +870,88 @@ } }, + "chartElement": { + "description": "Renders a bar, line, area, pie, or donut chart. Empty/missing series renders a 'no data' placeholder.", + "allOf": [ + { "$ref": "#/definitions/flexItemProperties" } + ], + "properties": { + "type": { "const": "chart" }, + "chart-type": { + "description": "Chart kind.", + "type": "string", + "enum": ["bar", "line", "area", "pie", "donut"] + }, + "categories": { + "description": "X-axis category labels (bar/line/area) or slice labels (pie/donut).", + "type": "array", + "items": { "type": "string" } + }, + "series": { + "description": "List of series. Each series has an optional label and a data value (inline number array or a {{ expr }} resolving to a number array).", + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "description": "Series label shown in the legend.", + "type": "string" + }, + "data": { + "description": "Inline array of numbers OR a {{ expr }} string resolving to a number array.", + "oneOf": [ + { "type": "array", "items": { "type": "number" } }, + { "type": "string" } + ] + } + } + } + }, + "palette": { + "description": "Named palette (default, ocean, sunset, forest, mono, vivid) or an explicit list of hex colors.", + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "theme": { + "description": "Per-element theme override.", + "type": "string", + "enum": ["light", "dark", "minimal"] + }, + "legend": { + "description": "Legend placement.", + "type": "string", + "enum": ["top", "bottom", "left", "right", "none"] + }, + "title": { + "description": "Optional chart title.", + "type": "string" + }, + "horizontal": { + "description": "Bar only. Render bars horizontally.", + "type": "boolean" + }, + "stacked": { + "description": "Bar only. Stack series on top of each other.", + "type": "boolean" + }, + "smooth": { + "description": "Line/area only. Smooth (curved) lines.", + "type": "boolean" + }, + "points": { + "description": "Line/area only. Draw point markers at each value.", + "type": "boolean" + }, + "labels": { + "description": "Pie/donut only. Slice label content.", + "type": "string", + "enum": ["percent", "value", "none"] + } + } + }, + "svgElement": { "description": "Renders an SVG image from a source path or inline content.", "allOf": [ From 1d57528686eb84b93cefaa52dc863c083fc0fab1 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:37:20 +0400 Subject: [PATCH 057/119] docs(wiki): rename chart example dir to singular so regenerate maps to chart-*.png --- examples/visual-docs/{charts => chart}/area-light.yaml | 0 examples/visual-docs/{charts => chart}/bar-light.yaml | 0 examples/visual-docs/{charts => chart}/donut-dark.yaml | 0 examples/visual-docs/{charts => chart}/line-minimal.yaml | 0 examples/visual-docs/{charts => chart}/pie-light.yaml | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename examples/visual-docs/{charts => chart}/area-light.yaml (100%) rename examples/visual-docs/{charts => chart}/bar-light.yaml (100%) rename examples/visual-docs/{charts => chart}/donut-dark.yaml (100%) rename examples/visual-docs/{charts => chart}/line-minimal.yaml (100%) rename examples/visual-docs/{charts => chart}/pie-light.yaml (100%) diff --git a/examples/visual-docs/charts/area-light.yaml b/examples/visual-docs/chart/area-light.yaml similarity index 100% rename from examples/visual-docs/charts/area-light.yaml rename to examples/visual-docs/chart/area-light.yaml diff --git a/examples/visual-docs/charts/bar-light.yaml b/examples/visual-docs/chart/bar-light.yaml similarity index 100% rename from examples/visual-docs/charts/bar-light.yaml rename to examples/visual-docs/chart/bar-light.yaml diff --git a/examples/visual-docs/charts/donut-dark.yaml b/examples/visual-docs/chart/donut-dark.yaml similarity index 100% rename from examples/visual-docs/charts/donut-dark.yaml rename to examples/visual-docs/chart/donut-dark.yaml diff --git a/examples/visual-docs/charts/line-minimal.yaml b/examples/visual-docs/chart/line-minimal.yaml similarity index 100% rename from examples/visual-docs/charts/line-minimal.yaml rename to examples/visual-docs/chart/line-minimal.yaml diff --git a/examples/visual-docs/charts/pie-light.yaml b/examples/visual-docs/chart/pie-light.yaml similarity index 100% rename from examples/visual-docs/charts/pie-light.yaml rename to examples/visual-docs/chart/pie-light.yaml From 803282e6eb6188f15a929b46966ec2bfd2dbd33e Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:41:45 +0400 Subject: [PATCH 058/119] feat(renderer): stack bars when stacked:true --- .../Rendering/ChartRenderer.cs | 121 +++++++++++++++++- .../Rendering/ChartStackedBarRenderTests.cs | 80 ++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 tests/FlexRender.Tests/Rendering/ChartStackedBarRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 40cad63..b3df895 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -190,6 +190,46 @@ private static (double Min, double Max) DataBounds(ChartElement chart) return (min, max); } + /// + /// Computes the value-axis bounds for stacked bars: the maximum is the largest per-category + /// stacked total (sum of all series' values at that category) and the minimum is the smallest + /// per-category stacked total clamped at zero, so the baseline stays at zero for non-negative data. + /// + private static (double Min, double Max) StackedBounds(ChartElement chart) + { + var categoryCount = 0; + foreach (var s in chart.Series) + categoryCount = Math.Max(categoryCount, s.Data.Count); + if (categoryCount == 0) + return (0d, 1d); + + var maxTotal = double.MinValue; + var minTotal = double.MaxValue; + for (var ci = 0; ci < categoryCount; ci++) + { + var positiveSum = 0d; + var negativeSum = 0d; + foreach (var s in chart.Series) + { + if (ci >= s.Data.Count) + continue; + var v = s.Data[ci]; + if (v >= 0d) + positiveSum += v; + else + negativeSum += v; + } + if (positiveSum > maxTotal) maxTotal = positiveSum; + if (negativeSum < minTotal) minTotal = negativeSum; + } + + var min = Math.Min(0d, minTotal); + var max = Math.Max(0d, maxTotal); + if (min == max) + max = min + 1d; + return (min, max); + } + /// Draws horizontal grid lines and y-axis tick labels for the given scale. private static void DrawGridAndYAxis( SKCanvas canvas, ChartTheme theme, in PlotArea plot, AxisScale scale, @@ -261,7 +301,11 @@ private static void DrawBars( SKCanvas canvas, ChartElement chart, ChartTheme theme, float width, float height, SKTypeface? typeface, bool antialias) { - var (dataMin, dataMax) = DataBounds(chart); + // Stacked vertical bars scale the value axis to per-category stacked totals; grouped (and + // horizontal) bars scale to the single-value range. Horizontal stacking is not implemented; + // a horizontal stacked chart falls back to grouped rendering. + var stacked = chart.Stacked && !chart.Horizontal; + var (dataMin, dataMax) = stacked ? StackedBounds(chart) : DataBounds(chart); var scale = AxisScale.Compute(dataMin, dataMax, targetTicks: 5); var hasTitle = !string.IsNullOrEmpty(chart.Title); @@ -286,6 +330,12 @@ private static void DrawBars( { DrawHorizontalBars(canvas, chart, theme, plot, scale, palette, seriesCount, categoryCount, antialias); } + else if (stacked) + { + DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias); + DrawStackedVerticalBars(canvas, chart, theme, plot, scale, palette, seriesCount, categoryCount, antialias); + DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias); + } else { DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias); @@ -294,6 +344,75 @@ private static void DrawBars( } } + /// + /// Draws stacked vertical bars: one full-width bar per category whose series segments are + /// stacked on top of one another from the zero baseline. Positive segments stack upward and + /// negative segments stack downward, each keeping a running pixel baseline per category. + /// + private static void DrawStackedVerticalBars( + SKCanvas canvas, ChartElement chart, ChartTheme theme, in PlotArea plot, AxisScale scale, + ChartPalette palette, int seriesCount, int categoryCount, bool antialias) + { + var mapper = new ValueMapper(scale.Min, scale.Max, plot.Top, plot.Bottom); + var zeroY = mapper.MapY(0d); + + var slot = plot.Width / categoryCount; + var padding = slot * 0.15f; + var barWidth = slot - (2f * padding); + + // Running pixel baselines per category: one for the upward (positive) stack and one for the + // downward (negative) stack, both starting at the zero line. + var positiveBaseline = new float[categoryCount]; + var negativeBaseline = new float[categoryCount]; + for (var ci = 0; ci < categoryCount; ci++) + { + positiveBaseline[ci] = zeroY; + negativeBaseline[ci] = zeroY; + } + + for (var si = 0; si < seriesCount; si++) + { + var data = chart.Series[si].Data; + using var paint = new SKPaint + { + Color = ColorParser.Parse(palette.ColorAt(si)), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + + for (var ci = 0; ci < data.Count; ci++) + { + var value = data[ci]; + if (value == 0d) + continue; + + // Segment height in pixels is the distance the mapped value moves from the zero line. + var segmentHeight = zeroY - mapper.MapY(value); + var barLeft = plot.Left + (slot * ci) + padding; + + float top, bottom; + if (value > 0d) + { + bottom = positiveBaseline[ci]; + top = bottom - segmentHeight; + positiveBaseline[ci] = top; + } + else + { + top = negativeBaseline[ci]; + bottom = top - segmentHeight; // segmentHeight is negative here, so bottom > top + negativeBaseline[ci] = bottom; + } + + var rect = new SKRect(barLeft, Math.Min(top, bottom), barLeft + barWidth, Math.Max(top, bottom)); + if (theme.BarCornerRadius > 0f) + canvas.DrawRoundRect(rect, theme.BarCornerRadius, theme.BarCornerRadius, paint); + else + canvas.DrawRect(rect, paint); + } + } + } + /// Draws bars rising vertically from the zero baseline, grouped along the X axis by category. private static void DrawVerticalBars( SKCanvas canvas, ChartElement chart, ChartTheme theme, in PlotArea plot, AxisScale scale, diff --git a/tests/FlexRender.Tests/Rendering/ChartStackedBarRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartStackedBarRenderTests.cs new file mode 100644 index 0000000..8e790c0 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartStackedBarRenderTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies stacked bar charts draw one bar per category with each series segment stacked on the +/// previous, so both series colors appear and the second series sits above the first. +/// +public sealed class ChartStackedBarRenderTests +{ + [Fact] + public void StackedVerticalBars_StackSeriesSegments() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromInline("first", new[] { 10d, 20d, 30d }), + ChartSeries.FromInline("second", new[] { 15d, 25d, 35d }) + }) + { + Categories = new[] { "A", "B", "C" }, + Legend = LegendPosition.None, + Stacked = true, + Palette = new ChartPalette(new[] { "#ff0000", "#0000ff" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true); + + var hasRed = false; + var hasBlue = false; + var stackedColumnFound = false; + + // In a stacked column both series share the same x-column: blue (series 1) is stacked + // directly above red (series 0). The discriminating signal versus grouped bars (which + // place each series in a separate x-column) is an x-column that contains BOTH red and + // blue with blue's lowest pixel sitting above red's highest pixel. + for (var x = 0; x < bitmap.Width; x++) + { + var minRedY = int.MaxValue; + var maxBlueY = int.MinValue; + var columnHasRed = false; + var columnHasBlue = false; + + for (var y = 0; y < bitmap.Height; y++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 200 && p.Green < 80 && p.Blue < 80) + { + hasRed = true; + columnHasRed = true; + if (y < minRedY) minRedY = y; + } + else if (p.Blue > 200 && p.Red < 80 && p.Green < 80) + { + hasBlue = true; + columnHasBlue = true; + if (y > maxBlueY) maxBlueY = y; + } + } + + if (columnHasRed && columnHasBlue && maxBlueY < minRedY) + stackedColumnFound = true; + } + + Assert.True(hasRed, "Expected red pixels from the first stacked series segment."); + Assert.True(hasBlue, "Expected blue pixels from the second stacked series segment."); + Assert.True(stackedColumnFound, + "Expected at least one x-column with blue stacked directly above red (stacked, not grouped)."); + } +} From 7c7d38e3f8047af33fdc41eba4d975388db2ae4e Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:42:55 +0400 Subject: [PATCH 059/119] feat(renderer): smooth line and area curves when smooth:true --- .../Rendering/ChartRenderer.cs | 67 ++++++++++++++--- .../Rendering/ChartSmoothLineRenderTests.cs | 75 +++++++++++++++++++ 2 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 tests/FlexRender.Tests/Rendering/ChartSmoothLineRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index b3df895..4ab6a04 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -525,18 +525,19 @@ private static void DrawLineOrArea( float X(int i) => data.Count > 1 ? plot.Left + (step * i) : plot.Left + (plot.Width / 2f); - using var linePath = new SKPath(); - linePath.MoveTo(X(0), mapper.MapY(data[0])); - for (var i = 1; i < data.Count; i++) - linePath.LineTo(X(i), mapper.MapY(data[i])); + // Build the series point list once and reuse it for both the line and area paths. + var points = new SKPoint[data.Count]; + for (var i = 0; i < data.Count; i++) + points[i] = new SKPoint(X(i), mapper.MapY(data[i])); + + using var linePath = BuildSeriesPath(points, chart.Smooth); if (fillArea) { - using var areaPath = new SKPath(); - areaPath.MoveTo(X(0), zeroY); - for (var i = 0; i < data.Count; i++) - areaPath.LineTo(X(i), mapper.MapY(data[i])); - areaPath.LineTo(X(data.Count - 1), zeroY); + using var areaPath = BuildSeriesPath(points, chart.Smooth); + // Close the area down to the zero baseline along the right edge and back to the start. + areaPath.LineTo(points[^1].X, zeroY); + areaPath.LineTo(points[0].X, zeroY); areaPath.Close(); var fillColor = color.WithAlpha(70); @@ -566,6 +567,54 @@ private static void DrawLineOrArea( DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias); } + /// + /// Builds an open path through the given series points. When is true, + /// adjacent points are joined with cubic Bézier segments derived from Catmull-Rom tangents, + /// producing a smooth curve; otherwise the points are joined with straight line segments. The + /// caller owns and must dispose the returned path. + /// + /// The series points in plot pixel coordinates. + /// Whether to build a smooth curve instead of straight segments. + /// A new starting at the first point and ending at the last. + private static SKPath BuildSeriesPath(SKPoint[] points, bool smooth) + { + var path = new SKPath(); + if (points.Length == 0) + return path; + + path.MoveTo(points[0]); + if (points.Length == 1) + return path; + + if (!smooth) + { + for (var i = 1; i < points.Length; i++) + path.LineTo(points[i]); + return path; + } + + // Catmull-Rom to cubic Bézier: for each segment P[i] -> P[i+1], the control points use the + // tangents at the endpoints, estimated from the neighbouring points (clamped at the ends). + for (var i = 0; i < points.Length - 1; i++) + { + var p0 = points[Math.Max(i - 1, 0)]; + var p1 = points[i]; + var p2 = points[i + 1]; + var p3 = points[Math.Min(i + 2, points.Length - 1)]; + + var c1 = new SKPoint( + p1.X + ((p2.X - p0.X) / 6f), + p1.Y + ((p2.Y - p0.Y) / 6f)); + var c2 = new SKPoint( + p2.X - ((p3.X - p1.X) / 6f), + p2.Y - ((p3.Y - p1.Y) / 6f)); + + path.CubicTo(c1, c2, p2); + } + + return path; + } + /// /// Draws a pie or donut chart from the first series' values. Slices are proportional to each /// value's share of the total; donut leaves a hollow center. diff --git a/tests/FlexRender.Tests/Rendering/ChartSmoothLineRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartSmoothLineRenderTests.cs new file mode 100644 index 0000000..3f5db41 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartSmoothLineRenderTests.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies smooth line charts draw a curve (green pixels, no crash) and that the curved render +/// differs measurably from the straight-segment render of the same data. +/// +public sealed class ChartSmoothLineRenderTests +{ + [Fact] + public void SmoothLine_DrawsGreenPixels() + { + using var bitmap = RenderLine(smooth: true); + + Assert.True(CountGreenPixels(bitmap) > 0, "Expected green pixels from the smooth line."); + } + + [Fact] + public void SmoothLine_DiffersFromStraightLine() + { + using var smoothBitmap = RenderLine(smooth: true); + using var straightBitmap = RenderLine(smooth: false); + + var smoothCount = CountGreenPixels(smoothBitmap); + var straightCount = CountGreenPixels(straightBitmap); + + Assert.True(smoothCount > 0, "Expected green pixels from the smooth line."); + Assert.True(straightCount > 0, "Expected green pixels from the straight line."); + + // A Catmull-Rom curve rounds/overshoots the zig-zag, so the set of covered pixels differs + // from the straight polyline. Comparing the green pixel counts is a tolerant proxy. + Assert.NotEqual(straightCount, smoothCount); + } + + private static SKBitmap RenderLine(bool smooth) + { + var chart = new ChartElement(ChartType.Line, new List + { + ChartSeries.FromInline("a", new[] { 10d, 40d, 10d, 40d }) + }) + { + Categories = new[] { "A", "B", "C", "D" }, + Legend = LegendPosition.None, + Smooth = smooth, + Palette = new ChartPalette(new[] { "#00ff00" }), + Theme = ChartThemes.Default + }; + + var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true); + return bitmap; + } + + private static int CountGreenPixels(SKBitmap bitmap) + { + var count = 0; + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Green > 150 && p.Red < 120 && p.Blue < 120) + count++; + } + return count; + } +} From 4ebedd40d66ff439f10b1f343bd31361f82249cd Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:44:34 +0400 Subject: [PATCH 060/119] feat(renderer): draw pie and donut slice labels --- .../Rendering/ChartRenderer.cs | 49 +++++++++++ .../Rendering/ChartPieLabelRenderTests.cs | 79 ++++++++++++++++++ .../Snapshots/golden/chart_donut_dark.png | Bin 13095 -> 15727 bytes .../Snapshots/golden/chart_pie_light.png | Bin 11494 -> 13650 bytes 4 files changed, 128 insertions(+) create mode 100644 tests/FlexRender.Tests/Rendering/ChartPieLabelRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 4ab6a04..f188e86 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -688,6 +688,55 @@ private static void DrawPie( }; canvas.DrawCircle(cx, cy, radius * 0.55f, hole); } + + DrawPieLabels(canvas, chart, theme, data, total, cx, cy, radius, isDonut, typeface, antialias); + } + + /// + /// Draws a text label at each slice's mid-angle when a typeface is available and the chart's + /// is not . Labels are placed on a + /// ring between the (optional) donut hole and the outer edge, centered on their anchor point. + /// + private static void DrawPieLabels( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + IReadOnlyList data, double total, float cx, float cy, float radius, bool isDonut, + SKTypeface? typeface, bool antialias) + { + if (typeface is null || chart.PieLabels == PieLabelMode.None || total <= 0d) + return; + + // For donuts the hole occupies the inner 55% of the radius, so place labels in the visible + // ring; for pies use ~0.7 of the radius from the center. + var labelRadius = isDonut ? radius * 0.78f : radius * 0.7f; + + using var font = new SKFont(typeface, theme.LabelSize); + using var paint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + + var startAngle = -90f; // matches the slice drawing start (12 o'clock) + for (var i = 0; i < data.Count; i++) + { + if (data[i] <= 0d) + continue; + + var sweep = (float)(data[i] / total * 360d); + var midAngle = startAngle + (sweep / 2f); + startAngle += sweep; + + // Skip very thin slices to avoid overlapping clutter. + if (sweep < 6f) + continue; + + var label = chart.PieLabels == PieLabelMode.Value + ? FormatTick(data[i]) + : $"{Math.Round(data[i] / total * 100d):0}%"; + + var rad = midAngle * (MathF.PI / 180f); + var tx = cx + (labelRadius * MathF.Cos(rad)); + var ty = cy + (labelRadius * MathF.Sin(rad)); + + var tw = font.MeasureText(label); + canvas.DrawText(label, tx - (tw / 2f), ty + (theme.LabelSize / 3f), SKTextAlign.Left, font, paint); + } } /// Draws the centred chart title in the reserved top band, when present. diff --git a/tests/FlexRender.Tests/Rendering/ChartPieLabelRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartPieLabelRenderTests.cs new file mode 100644 index 0000000..0a4f925 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartPieLabelRenderTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies pie/donut slice labels render as text inside the pie area when a typeface is supplied +/// and the chart's is not . +/// +public sealed class ChartPieLabelRenderTests +{ + [Fact] + public void Pie_DrawsSliceLabelsInsidePieArea() + { + using var typeface = LoadInter(); + Assert.NotNull(typeface); + + var chart = new ChartElement(ChartType.Pie, new List + { + ChartSeries.FromInline(null, new[] { 30d, 50d, 20d }) + }) + { + Categories = new[] { "Direct", "Social", "Search" }, + Legend = LegendPosition.None, + PieLabels = PieLabelMode.Percent, + Palette = new ChartPalette(new[] { "#cccccc", "#dddddd", "#eeeeee" }), + Theme = ChartThemes.Default + }; + + const int size = 320; + using var bitmap = new SKBitmap(size, size, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, size, size, typeface, antialias: true); + + // Slice labels sit on a ring around the pie center. Scan a central band (excluding the + // outer edges and any legend area) for dark text pixels drawn over the light slices. + var bandTop = size / 4; + var bandBottom = size * 3 / 4; + Assert.True(HasDarkPixelInBand(bitmap, bandTop, bandBottom), + "Expected dark slice-label text pixels within the pie's central band."); + } + + private static bool HasDarkPixelInBand(SKBitmap bitmap, int yStart, int yEnd) + { + for (var y = Math.Max(0, yStart); y < Math.Min(bitmap.Height, yEnd); y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red < 130 && p.Green < 130 && p.Blue < 130) + return true; + } + return false; + } + + private static SKTypeface? LoadInter() + { + var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; + var current = asmDir; + while (!string.IsNullOrEmpty(current)) + { + if (Directory.GetFiles(current, "*.csproj").Length > 0) + { + var fontPath = Path.Combine(current, "Snapshots", "Fonts", "Inter-Regular.ttf"); + return File.Exists(fontPath) ? SKTypeface.FromFile(fontPath) : null; + } + current = Directory.GetParent(current)?.FullName; + } + return null; + } +} diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_donut_dark.png b/tests/FlexRender.Tests/Snapshots/golden/chart_donut_dark.png index 97fed3d75a86e36f6a13fd13aae44fec04006f8c..c2efcf984f5aa2923b78cc63bd1db5692d708e7d 100644 GIT binary patch literal 15727 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 V!|npbcR&XG literal 13095 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 aXLe}lb5S?A3I6j1Lsd~jp-j#q;Qs(XC!Q<- diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_pie_light.png b/tests/FlexRender.Tests/Snapshots/golden/chart_pie_light.png index d4757f8af328d4bc41efafbc966d54479dab3bb6..28881c3c56b633cdc44947a75f2dfc02fbc60b11 100644 GIT binary patch literal 13650 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 XXk!6;KM~+ZQXmb4HoQXFGW34{#X(IJ literal 11494 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 Date: Sat, 13 Jun 2026 21:54:12 +0400 Subject: [PATCH 061/119] docs: add charts phase 3 implementation plan --- .../plans/2026-06-13-charts-phase3.md | 2291 +++++++++++++++++ 1 file changed, 2291 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-13-charts-phase3.md diff --git a/docs/superpowers/plans/2026-06-13-charts-phase3.md b/docs/superpowers/plans/2026-06-13-charts-phase3.md new file mode 100644 index 0000000..703f674 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-charts-phase3.md @@ -0,0 +1,2291 @@ +# Charts (Phase 3) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend the `chart` element with five new chart-types — `scatter`, `bubble`, `gauge`, `progress`, `sparkline` — so LLM agents can render XY point clouds, sized-bubble plots, single-value dial/ring indicators, and tiny inline sparklines from declarative YAML, with zero styling decisions. + +**Architecture:** Phase 2 series carry a flat `IReadOnlyList Data`. Scatter/bubble need XY(R) tuples, so `ChartSeries` gains a parallel `IReadOnlyList Points` representation (`ChartPoint` is a new `readonly record struct (double X, double Y, double R)` in `FlexRender.Core/Charts/`). Flat-data series remain byte-for-byte unchanged — `Points` is empty for them and `Data` is empty for tuple series. `ChartElement` gains `Value`/`Max`/`ValueLabel` for the single-value gauge/progress indicators. Parsing (`ChartParsers`) grows a tuple-aware data path (detecting array-of-arrays) plus `value`/`max`/`label` parsing; all new YAML props register in `KnownProperties.Chart`. Rendering adds five `case` arms to `ChartRenderer.DrawSeries`: scatter/bubble compute an X *and* Y `AxisScale` (a small local `MapX` mirrors the existing `ValueMapper.MapY`), gauge/progress draw arcs via `SKPath.AddArc`/`SKCanvas.DrawArc`, and sparkline reuses the existing `BuildSeriesPath` with no chrome. Expression-bound data stays numeric-array only this phase; tuple data is inline-only (a follow-up note covers array-of-arrays binding). + +**Tech Stack:** .NET 10, C# latest, xUnit, SkiaSharp, YamlDotNet. AOT-safe (no reflection, no `dynamic`, no runtime regex), `sealed` classes, `ArgumentNullException.ThrowIfNull`, switch-based dispatch, XML docs on all public API. + +--- + +## Conventions used throughout this plan + +- All commands run from repo root `/Users/robonet/Projects/SkiaLayout`. +- Branch is already `feature/charts-and-shapes`. Do NOT create worktrees. Do NOT merge to `main`. +- Build: `dotnet build FlexRender.slnx`. Test (authoritative, net10.0): `dotnet test FlexRender.slnx --framework net10.0 --filter ...`. The net8.0 test host cannot launch in this environment — always pass `--framework net10.0`. +- NEVER pipe `dotnet` output through `tail`/`head`/`grep`. Run commands directly. +- Commit messages use Conventional Commits, NO attribution / Co-Authored-By lines. The signing key is missing — every commit uses `--no-gpg-sign`. +- After every code edit the build must be warning-free (`TreatWarningsAsErrors=true`). Watch for CA1859 (return concrete types where the concrete type is used internally), unused-parameter, and unused-using analyzer errors. +- All new public API gets XML docs. All new YAML props get registered in `KnownProperties.Chart`. Never weaken `ResourceLimits`. +- Snapshot goldens are generated by running the snapshot test once with `UPDATE_SNAPSHOTS=true dotnet test FlexRender.slnx --framework net10.0 --filter ...`, then committed. + +## File structure (created / modified across all tasks) + +Created: +- `src/FlexRender.Core/Charts/ChartPoint.cs` (XY(R) tuple struct) +- Test files (one per task, see each task) + +Modified: +- `src/FlexRender.Core/Charts/ChartEnums.cs` (add `Scatter`, `Bubble`, `Gauge`, `Progress`, `Sparkline` to `ChartType`) +- `src/FlexRender.Core/Parsing/Ast/ChartSeries.cs` (add `Points`, `FromPoints`, keep `WithData`/`FromInline`/`FromExpression`) +- `src/FlexRender.Core/Parsing/Ast/ChartElement.cs` (add `Value`/`Max`/`ValueLabel` props; clone them) +- `src/FlexRender.Yaml/Parsing/ChartParsers.cs` (tuple data path; `value`/`max`/`label`; chart-type error message) +- `src/FlexRender.Yaml/Parsing/KnownProperties.cs` (add `value`, `max`, `label` to `Chart` set) +- `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` (5 new `DrawSeries` arms + draw methods) +- Docs: `llms.txt`, `llms-full.txt`, `docs/wiki/Element-Reference.md`, `docs/wiki/Visual-Reference.md`, + `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`, + `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` + +## Highest-risk tasks (flagged for extra review) + +1. **Task 2–3 — scatter/bubble tuple-data representation + parsing.** `ChartSeries.Points` must coexist cleanly with flat `Data` (Phase-2 series untouched), and `ChartParsers` must detect array-of-arrays vs flat-array and validate tuple arity (2 for scatter, 2-or-3 for bubble), non-numeric, and non-finite, naming the series in every error. +2. **Task 8 — scatter/bubble X-axis scaling.** The codebase only scales Y (`ValueMapper.MapY`). Scatter/bubble need a *second* `AxisScale` over the X data range and a local `MapX` mirroring `MapY`. Degenerate spans (single point, identical X) must not divide by zero. +3. **Task 9–10 — gauge/progress arc geometry.** Arc start/sweep angles, clamping `value` into `[0, max]`, donut-style ring thickness, and the centered value label must be correct and dispose all `SKPath`/`SKPaint`/`SKFont` with balanced `Save`/`Restore`. + +--- + +## Task 1: Extend ChartType enum with the five Phase-3 types + +**Files:** +- Modify: `src/FlexRender.Core/Charts/ChartEnums.cs` +- Test: `tests/FlexRender.Tests/Charts/ChartTypePhase3Tests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Charts/ChartTypePhase3Tests.cs`: + +```csharp +using System; +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Sanity tests for the Phase-3 chart type enum members. +/// +public sealed class ChartTypePhase3Tests +{ + [Theory] + [InlineData("Scatter")] + [InlineData("Bubble")] + [InlineData("Gauge")] + [InlineData("Progress")] + [InlineData("Sparkline")] + public void ChartType_HasAllPhase3Members(string name) + { + Assert.True(Enum.TryParse(name, out _)); + } + + [Theory] + [InlineData("Bar")] + [InlineData("Line")] + [InlineData("Area")] + [InlineData("Pie")] + [InlineData("Donut")] + public void ChartType_StillHasPhase2Members(string name) + { + Assert.True(Enum.TryParse(name, out _)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartTypePhase3Tests"` +Expected: FAIL — `Scatter`/`Bubble`/`Gauge`/`Progress`/`Sparkline` not defined (the Phase-3 cases parse to false). + +- [ ] **Step 3: Add the enum members** + +In `src/FlexRender.Core/Charts/ChartEnums.cs`, replace the `ChartType` enum body so the closing `Donut` line ends with a comma and the new members follow (keep the existing five members and their docs unchanged): + +```csharp +public enum ChartType +{ + /// Vertical (or horizontal) bar chart. + Bar, + + /// Line chart. + Line, + + /// Filled area chart. + Area, + + /// Pie chart. + Pie, + + /// Donut (ring) chart. + Donut, + + /// XY scatter plot ([x, y] tuples). + Scatter, + + /// Bubble plot ([x, y, r] tuples; the third value sizes the bubble). + Bubble, + + /// Single-value arc/dial gauge. + Gauge, + + /// Single-value progress ring. + Progress, + + /// Tiny inline line chart with no axes, labels, or legend. + Sparkline +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartTypePhase3Tests"` +Expected: PASS (10 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Charts/ChartEnums.cs tests/FlexRender.Tests/Charts/ChartTypePhase3Tests.cs +git commit --no-gpg-sign -m "feat(charts): add scatter/bubble/gauge/progress/sparkline chart types" +``` + +--- + +## Task 2: ChartPoint struct + ChartSeries.Points representation + +The flat `Data` path is untouched. Tuple series carry a parallel `Points` list; flat series leave `Points` empty and vice versa. + +**Files:** +- Create: `src/FlexRender.Core/Charts/ChartPoint.cs` +- Modify: `src/FlexRender.Core/Parsing/Ast/ChartSeries.cs` +- Test: `tests/FlexRender.Tests/Charts/ChartPointTests.cs` (create) +- Test: `tests/FlexRender.Tests/Parsing/Ast/ChartSeriesPointsTests.cs` (create) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/FlexRender.Tests/Charts/ChartPointTests.cs`: + +```csharp +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for the XY(R) tuple struct. +/// +public sealed class ChartPointTests +{ + [Fact] + public void Constructor_StoresXYR() + { + var p = new ChartPoint(1.5d, 2.5d, 3.5d); + Assert.Equal(1.5d, p.X); + Assert.Equal(2.5d, p.Y); + Assert.Equal(3.5d, p.R); + } + + [Fact] + public void Xy_DefaultsRadiusToZero() + { + var p = ChartPoint.Xy(4d, 5d); + Assert.Equal(4d, p.X); + Assert.Equal(5d, p.Y); + Assert.Equal(0d, p.R); + } +} +``` + +Create `tests/FlexRender.Tests/Parsing/Ast/ChartSeriesPointsTests.cs`: + +```csharp +using System; +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the tuple-point representation on . +/// +public sealed class ChartSeriesPointsTests +{ + [Fact] + public void FromPoints_StoresLabelAndPoints_DataEmpty() + { + var points = new List { ChartPoint.Xy(1d, 2d), ChartPoint.Xy(3d, 4d) }; + var series = ChartSeries.FromPoints("xy", points); + + Assert.Equal("xy", series.Label); + Assert.Equal(2, series.Points.Count); + Assert.Equal(1d, series.Points[0].X); + Assert.Empty(series.Data); + Assert.Null(series.DataExpression); + } + + [Fact] + public void FromInline_LeavesPointsEmpty() + { + var series = ChartSeries.FromInline("flat", new[] { 1d, 2d, 3d }); + Assert.Empty(series.Points); + Assert.Equal(3, series.Data.Count); + } + + [Fact] + public void FromPoints_NullPoints_Throws() + { + Assert.Throws(() => ChartSeries.FromPoints("x", null!)); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartPointTests|FullyQualifiedName~ChartSeriesPointsTests"` +Expected: BUILD FAILURE — `ChartPoint` and `ChartSeries.FromPoints`/`Points` not defined. + +- [ ] **Step 3: Create ChartPoint** + +Create `src/FlexRender.Core/Charts/ChartPoint.cs`: + +```csharp +namespace FlexRender.Charts; + +/// +/// A single XY data point with an optional radius, used by scatter ([x, y]) and bubble +/// ([x, y, r]) series. Renderer-agnostic; is zero for scatter points. +/// +/// The X coordinate in data space. +/// The Y coordinate in data space. +/// The bubble radius in data space (zero for scatter points). +public readonly record struct ChartPoint(double X, double Y, double R) +{ + /// + /// Creates a point with no radius (scatter). + /// + /// The X coordinate. + /// The Y coordinate. + /// A with set to zero. + public static ChartPoint Xy(double x, double y) => new(x, y, 0d); +} +``` + +- [ ] **Step 4: Add Points to ChartSeries** + +In `src/FlexRender.Core/Parsing/Ast/ChartSeries.cs`: + +1. Add `using FlexRender.Charts;` to the using block. + +2. Add an empty-points sentinel next to the existing `Empty` field: + +```csharp + private static readonly IReadOnlyList EmptyPoints = Array.Empty(); +``` + +3. Replace the private constructor to also accept points: + +```csharp + private ChartSeries(string? label, string? dataExpression, IReadOnlyList data, IReadOnlyList points) + { + Label = label; + DataExpression = dataExpression; + Data = data; + Points = points; + } +``` + +4. Add the `Points` property after `Data`: + +```csharp + /// + /// Gets the resolved XY(R) tuple points for scatter/bubble series. Empty for flat numeric + /// series (which use instead). + /// + public IReadOnlyList Points { get; } +``` + +5. Update the three existing factory/`WithData` returns to pass the extra argument. `FromInline`: + +```csharp + return new ChartSeries(label, dataExpression: null, data, EmptyPoints); +``` + +`FromExpression`: + +```csharp + return new ChartSeries(label, dataExpression, Empty, EmptyPoints); +``` + +`WithData`: + +```csharp + return new ChartSeries(Label, DataExpression, data, EmptyPoints); +``` + +6. Add the new `FromPoints` factory after `FromExpression`: + +```csharp + /// + /// Creates a series with inline XY(R) tuple points (scatter/bubble). + /// + /// The optional legend label. + /// The tuple points. + /// A new whose is empty. + /// Thrown when is null. + public static ChartSeries FromPoints(string? label, IReadOnlyList points) + { + ArgumentNullException.ThrowIfNull(points); + return new ChartSeries(label, dataExpression: null, Empty, points); + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartPointTests|FullyQualifiedName~ChartSeriesPointsTests"` +Expected: PASS (5 cases). + +- [ ] **Step 6: Verify existing chart tests still pass (no Phase-2 regression)** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSeriesTests"` +Expected: PASS (existing Phase-2 ChartSeries tests unaffected). + +- [ ] **Step 7: Commit** + +```bash +git add src/FlexRender.Core/Charts/ChartPoint.cs src/FlexRender.Core/Parsing/Ast/ChartSeries.cs tests/FlexRender.Tests/Charts/ChartPointTests.cs tests/FlexRender.Tests/Parsing/Ast/ChartSeriesPointsTests.cs +git commit --no-gpg-sign -m "feat(charts): add ChartPoint tuple representation to ChartSeries" +``` + +--- + +## Task 3: ChartElement gains Value / Max / ValueLabel (gauge/progress) + +**Files:** +- Modify: `src/FlexRender.Core/Parsing/Ast/ChartElement.cs` +- Test: `tests/FlexRender.Tests/Parsing/Ast/ChartElementGaugeTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Ast/ChartElementGaugeTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the gauge/progress single-value properties on . +/// +public sealed class ChartElementGaugeTests +{ + [Fact] + public void Defaults_ValueMaxLabelAreNull() + { + var chart = new ChartElement(ChartType.Gauge, new List()); + Assert.Null(chart.Value); + Assert.Null(chart.Max); + Assert.Null(chart.ValueLabel); + } + + [Fact] + public void CloneWithSubstitution_PreservesValueMaxLabel() + { + var chart = new ChartElement(ChartType.Progress, new List()) + { + Value = 72d, + Max = 100d, + ValueLabel = "CPU" + }; + + var clone = (ChartElement)chart.CloneWithSubstitution(s => s); + + Assert.Equal(72d, clone.Value); + Assert.Equal(100d, clone.Max); + Assert.Equal("CPU", clone.ValueLabel); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementGaugeTests"` +Expected: BUILD FAILURE — `Value`/`Max`/`ValueLabel` not defined. + +- [ ] **Step 3: Add the properties + clone** + +In `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`, add these three properties after `PieLabels`: + +```csharp + /// Gets or sets the indicator value for gauge/progress charts. Null renders a "no data" placeholder. + public double? Value { get; set; } + + /// Gets or sets the indicator maximum for gauge/progress charts. Null defaults to 100. + public double? Max { get; set; } + + /// Gets or sets the centered caption for gauge/progress charts (distinct from ). + public string? ValueLabel { get; set; } +``` + +In `CloneWithSubstitution`, add the three to the object initializer (after `PieLabels = PieLabels`): + +```csharp + PieLabels = PieLabels, + Value = Value, + Max = Max, + ValueLabel = ValueLabel +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementGaugeTests"` +Expected: PASS (2 cases). + +- [ ] **Step 5: Verify Phase-2 ChartElement tests still pass** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementTests"` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Ast/ChartElement.cs tests/FlexRender.Tests/Parsing/Ast/ChartElementGaugeTests.cs +git commit --no-gpg-sign -m "feat(ast): add Value/Max/ValueLabel to ChartElement for gauge and progress" +``` + +--- + +## Task 4: Parse scatter/bubble tuple data + update chart-type error message + +`ParseOneSeries` currently flattens a scalar sequence. We add a tuple path: when the `data:` sequence's first item is itself a sequence, parse each item as an XY (scatter) or XY(R) (bubble) tuple. We pass the `ChartType` down so arity rules differ. + +**Files:** +- Modify: `src/FlexRender.Yaml/Parsing/ChartParsers.cs` +- Test: `tests/FlexRender.Tests/Parsing/ChartScatterParsersTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/ChartScatterParsersTests.cs`: + +```csharp +using FlexRender.Charts; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for parsing scatter/bubble tuple series and the updated chart-type validation. +/// +public sealed class ChartScatterParsersTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_ScatterTuples_ProducesPoints() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: scatter + width: 400 + height: 300 + series: + - label: cloud + data: [[1, 10], [2, 25], [3, 18]] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal(ChartType.Scatter, chart.ChartType); + var pts = chart.Series[0].Points; + Assert.Equal(3, pts.Count); + Assert.Equal(1d, pts[0].X); + Assert.Equal(10d, pts[0].Y); + Assert.Equal(0d, pts[0].R); + Assert.Empty(chart.Series[0].Data); + } + + [Fact] + public void Parse_BubbleTriples_ProducesPointsWithRadius() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: bubble + width: 400 + height: 300 + series: + - data: [[1, 10, 5], [2, 25, 12]] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + var pts = chart.Series[0].Points; + Assert.Equal(2, pts.Count); + Assert.Equal(5d, pts[0].R); + Assert.Equal(12d, pts[1].R); + } + + [Fact] + public void Parse_ScatterTupleWrongArity_Throws() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: scatter + width: 400 + height: 300 + series: + - label: bad + data: [[1, 2, 3]] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("bad", ex.Message); + } + + [Fact] + public void Parse_ScatterTupleNonNumeric_Throws() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: scatter + width: 400 + height: 300 + series: + - data: [[1, "x"]] + """; + + Assert.Throws(() => _parser.Parse(yaml)); + } + + [Fact] + public void Parse_UnknownChartType_ErrorListsNewTypes() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: nope + width: 400 + height: 300 + series: + - data: [1, 2] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("scatter", ex.Message); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartScatterParsersTests"` +Expected: FAIL — tuple sequences raise the existing non-numeric error; new-type list missing from message. + +- [ ] **Step 3: Implement tuple parsing** + +In `src/FlexRender.Yaml/Parsing/ChartParsers.cs`: + +1. Add `using FlexRender.Charts;` is already present. Ensure `using System;` covers `Array` (it is used). No new usings needed beyond `YamlDotNet.RepresentationModel` (already imported). + +2. Update `ParseChartType` error message to list all types: + +```csharp + throw new TemplateParseException( + $"Unknown chart-type '{raw}'. Valid values: bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline."); +``` + +3. Thread the chart type through series parsing. Change the `ParseSeries` call in `ParseChartElement` to pass `chartType`: + +```csharp + var series = ParseSeries(node, chartType, maxSeries, maxDataPoints); +``` + +4. Update `ParseSeries` signature and the `ParseOneSeries` call inside it: + +```csharp + private static List ParseSeries(YamlMappingNode node, ChartType chartType, int maxSeries, int maxDataPoints) + { + var result = new List(); + + if (!TryGetSequence(node, "series", out var seriesSeq)) + return result; + + if (seriesSeq.Children.Count > maxSeries) + { + throw new TemplateParseException( + $"Chart has {seriesSeq.Children.Count} series, which exceeds the maximum of {maxSeries}."); + } + + foreach (var item in seriesSeq.Children) + { + if (item is not YamlMappingNode seriesNode) + throw new TemplateParseException("Each entry in 'series' must be a mapping with a 'data' field."); + + result.Add(ParseOneSeries(seriesNode, chartType, maxDataPoints)); + } + + return result; + } +``` + +5. Replace the whole `ParseOneSeries` method with a version that branches on tuple-vs-flat data. The first child being a sequence means tuple data: + +```csharp + private static ChartSeries ParseOneSeries(YamlMappingNode seriesNode, ChartType chartType, int maxDataPoints) + { + var label = GetStringValue(seriesNode, "label"); + var isTupleType = chartType is ChartType.Scatter or ChartType.Bubble; + + // Inline array form. + if (TryGetSequence(seriesNode, "data", out var dataSeq)) + { + if (dataSeq.Children.Count > maxDataPoints) + { + throw new TemplateParseException( + $"Series '{label ?? "(unlabeled)"}' has {dataSeq.Children.Count} data points, which exceeds the maximum of {maxDataPoints}."); + } + + // Tuple data: the items are themselves sequences ([x, y] or [x, y, r]). + if (dataSeq.Children.Count > 0 && dataSeq.Children[0] is YamlSequenceNode) + return ParseTupleSeries(label, dataSeq, isTupleType); + + // Flat numeric data (bar/line/area/pie/donut/sparkline/gauge-progress series). + var values = new List(dataSeq.Children.Count); + foreach (var v in dataSeq.Children) + { + if (v is not YamlScalarNode scalar + || !double.TryParse(scalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) + || !double.IsFinite(d)) + { + throw new TemplateParseException( + $"Series '{label ?? "(unlabeled)"}' contains a non-numeric data value '{(v as YamlScalarNode)?.Value}'."); + } + values.Add(d); + } + return ChartSeries.FromInline(label, values); + } + + // Expression form (scalar string containing {{ }}). Tuple-typed charts only support + // inline tuple data this phase; expression-bound tuples are a follow-up. + var expr = GetStringValue(seriesNode, "data"); + if (!string.IsNullOrWhiteSpace(expr)) + return ChartSeries.FromExpression(label, expr); + + // No data at all -> empty inline series (renders as "no data" if all series empty). + return ChartSeries.FromInline(label, Array.Empty()); + } + + /// + /// Parses an array-of-arrays data sequence into XY (scatter) or XY(R) (bubble) tuple points. + /// + /// The series label, used in error messages. + /// The data sequence whose items are 2- or 3-element scalar sequences. + /// Whether a third (radius) element is permitted (bubble). + /// A point-bearing . + /// Thrown on wrong arity, non-numeric, or non-finite tuple values. + private static ChartSeries ParseTupleSeries(string? label, YamlSequenceNode dataSeq, bool allowRadius) + { + var points = new List(dataSeq.Children.Count); + foreach (var item in dataSeq.Children) + { + if (item is not YamlSequenceNode tuple) + { + throw new TemplateParseException( + $"Series '{label ?? "(unlabeled)"}' mixes tuple and scalar data; every item must be an [x, y] (or [x, y, r]) array."); + } + + var arity = tuple.Children.Count; + var maxArity = allowRadius ? 3 : 2; + if (arity < 2 || arity > maxArity) + { + throw new TemplateParseException( + $"Series '{label ?? "(unlabeled)"}' has a tuple with {arity} elements; expected 2{(allowRadius ? " or 3" : string.Empty)}."); + } + + var x = ParseTupleScalar(tuple.Children[0], label); + var y = ParseTupleScalar(tuple.Children[1], label); + var r = arity == 3 ? ParseTupleScalar(tuple.Children[2], label) : 0d; + points.Add(new ChartPoint(x, y, r)); + } + + return ChartSeries.FromPoints(label, points); + } + + /// Parses a single tuple element as a finite double, raising a named error otherwise. + /// The tuple element node. + /// The series label, used in error messages. + /// The parsed finite double. + /// Thrown when the value is non-numeric or non-finite. + private static double ParseTupleScalar(YamlNode node, string? label) + { + if (node is YamlScalarNode scalar + && double.TryParse(scalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) + && double.IsFinite(d)) + { + return d; + } + + throw new TemplateParseException( + $"Series '{label ?? "(unlabeled)"}' contains a non-numeric tuple value '{(node as YamlScalarNode)?.Value}'."); + } +``` + +Note: `YamlNode` is the base type from `YamlDotNet.RepresentationModel` (already imported via the existing `using`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartScatterParsersTests"` +Expected: PASS (5 cases). + +- [ ] **Step 5: Verify Phase-2 parser tests still pass** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartParsersTests"` +Expected: PASS (flat-data parsing unaffected). + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Yaml/Parsing/ChartParsers.cs tests/FlexRender.Tests/Parsing/ChartScatterParsersTests.cs +git commit --no-gpg-sign -m "feat(yaml): parse scatter/bubble tuple series data" +``` + +--- + +## Task 5: Parse gauge/progress value/max/label + register KnownProperties + +**Files:** +- Modify: `src/FlexRender.Yaml/Parsing/ChartParsers.cs` +- Modify: `src/FlexRender.Yaml/Parsing/KnownProperties.cs` +- Test: `tests/FlexRender.Tests/Parsing/ChartGaugeParsersTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/ChartGaugeParsersTests.cs`: + +```csharp +using FlexRender.Charts; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for parsing gauge/progress value/max/label and property validation. +/// +public sealed class ChartGaugeParsersTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_Gauge_ReadsValueMaxLabel() + { + const string yaml = """ + canvas: + width: 300 + layout: + - type: chart + chart-type: gauge + width: 300 + height: 200 + value: 72 + max: 100 + label: CPU + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal(ChartType.Gauge, chart.ChartType); + Assert.Equal(72d, chart.Value); + Assert.Equal(100d, chart.Value is null ? 0d : chart.Max); + Assert.Equal("CPU", chart.ValueLabel); + } + + [Fact] + public void Parse_Progress_DefaultsMaxLabelToNull() + { + const string yaml = """ + canvas: + width: 300 + layout: + - type: chart + chart-type: progress + width: 300 + height: 80 + value: 40 + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal(40d, chart.Value); + Assert.Null(chart.Max); + Assert.Null(chart.ValueLabel); + } + + [Fact] + public void Parse_GaugeWithKnownProps_DoesNotThrow() + { + const string yaml = """ + canvas: + width: 300 + layout: + - type: chart + chart-type: gauge + width: 300 + height: 200 + value: 50 + max: 80 + label: Disk + theme: dark + """; + + var template = _parser.Parse(yaml); + Assert.IsType(template.Elements[0]); + } + + [Fact] + public void Parse_TypoInValue_Throws() + { + const string yaml = """ + canvas: + width: 300 + layout: + - type: chart + chart-type: gauge + width: 300 + height: 200 + valeu: 50 + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("valeu", ex.Message); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartGaugeParsersTests"` +Expected: FAIL — `value`/`max`/`label` are unknown properties (parser rejects them) and not yet read onto the element. + +- [ ] **Step 3: Register the new props in KnownProperties** + +In `src/FlexRender.Yaml/Parsing/KnownProperties.cs`, the `Chart` set, add `"value", "max", "label"` to the list (note: `label` is the gauge caption; it does not conflict with the per-series `label` which is validated inside the series mapping, not the chart mapping): + +```csharp + internal static readonly HashSet Chart = BuildSet(FlexItemProperties, + [ + "chart-type", "categories", "series", "palette", "theme", "legend", "title", + "horizontal", "stacked", "smooth", "points", "labels", + "value", "max", "label", + "background", "rotate", "padding", "margin" + ]); +``` + +- [ ] **Step 4: Read value/max/label in the parser** + +In `src/FlexRender.Yaml/Parsing/ChartParsers.cs`, inside `ParseChartElement`'s object initializer, add three lines after `PieLabels = ParsePieLabels(node),`: + +```csharp + Value = GetDoubleValue(node, "value"), + Max = GetDoubleValue(node, "max"), + ValueLabel = GetStringValue(node, "label"), +``` + +`GetDoubleValue` returns `double?` and is already imported via the `static ... YamlPropertyHelpers` using. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartGaugeParsersTests"` +Expected: PASS (4 cases). + +- [ ] **Step 6: Verify the chart KnownProperties tests still pass** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartKnownPropertiesTests"` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/FlexRender.Yaml/Parsing/ChartParsers.cs src/FlexRender.Yaml/Parsing/KnownProperties.cs tests/FlexRender.Tests/Parsing/ChartGaugeParsersTests.cs +git commit --no-gpg-sign -m "feat(yaml): parse gauge/progress value/max/label and register chart props" +``` + +--- + +## Task 6: Renderer — DataBounds tuple awareness + XY bounds helper + +Before drawing scatter/bubble we need X and Y data ranges. `DataBounds` only walks flat `Data`. Add a `PointBounds` helper that returns X and Y min/max across all series' `Points`. This is pure and unit-testable via a thin internal accessor — but to avoid widening internals, we test it indirectly through scatter rendering in Task 8. This task adds the helper only; it compiles but is not yet wired. + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` + +- [ ] **Step 1: Add the PointBounds helper** + +In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add this method directly after the existing `DataBounds` method: + +```csharp + /// + /// Computes the X and Y data ranges across all series' tuple points (scatter/bubble). + /// Returns unit ranges when there are no points so a chart can still draw. + /// + private static (double MinX, double MaxX, double MinY, double MaxY) PointBounds(ChartElement chart) + { + var minX = double.MaxValue; + var maxX = double.MinValue; + var minY = double.MaxValue; + var maxY = double.MinValue; + + foreach (var s in chart.Series) + { + foreach (var p in s.Points) + { + if (p.X < minX) minX = p.X; + if (p.X > maxX) maxX = p.X; + if (p.Y < minY) minY = p.Y; + if (p.Y > maxY) maxY = p.Y; + } + } + + if (minX == double.MaxValue) + return (0d, 1d, 0d, 1d); + return (minX, maxX, minY, maxY); + } +``` + +- [ ] **Step 2: Add a HasAnyPoints helper and extend HasAnyData** + +Replace the existing `HasAnyData` method body so it also treats tuple series and gauge/progress values as "has data": + +```csharp + /// Returns whether the chart has anything to draw (flat data, tuple points, or a gauge value). + private static bool HasAnyData(ChartElement chart) + { + if (chart.ChartType is ChartType.Gauge or ChartType.Progress) + return chart.Value is not null; + + foreach (var s in chart.Series) + { + if (s.Data.Count > 0 || s.Points.Count > 0) + return true; + } + return false; + } +``` + +- [ ] **Step 3: Verify the build compiles** + +Run: `dotnet build FlexRender.slnx` +Expected: BUILD SUCCEEDED. (CA1859 watch: both helpers return value tuples / bool, no interface returns. The new `PointBounds` is currently unused but referenced in Task 8 within the same file — if the analyzer flags it as unused before Task 8, proceed directly to Task 8 in the same working session so it becomes used; do not commit this task standalone if `dotnet build` reports an unused-private-member error.) + +- [ ] **Step 4: Commit (only if build is clean)** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +git commit --no-gpg-sign -m "feat(skia): add chart tuple-point bounds and gauge data detection" +``` + +> If `dotnet build` flags `PointBounds` as an unused private member (IDE0051/CA-equivalent error under TreatWarningsAsErrors), skip the standalone commit and fold this task's edits into the Task 8 commit instead. + +--- + +## Task 7: Renderer — sparkline (minimal line, no chrome) + +Sparkline reuses flat `Data` and the existing `BuildSeriesPath` (honoring `Smooth`/`ShowPoints`) but draws no grid, axes, labels, legend, or title — just the line filling the box with a small inset. + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Test: `tests/FlexRender.Tests/Rendering/ChartSparklineRenderTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ChartSparklineRenderTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies the sparkline draws a colored line with no axis chrome. +/// +public sealed class ChartSparklineRenderTests +{ + [Fact] + public void Sparkline_DrawsColoredLine() + { + var chart = new ChartElement(ChartType.Sparkline, new List + { + ChartSeries.FromInline(null, new[] { 3d, 8d, 4d, 10d, 6d }) + }) + { + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(120, 40, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 120f, 40f, typeface: null, antialias: true); + + Assert.True(HasRedPixel(bitmap), "Expected a red sparkline."); + } + + private static bool HasRedPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 180 && p.Green < 100 && p.Blue < 100) + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSparklineRenderTests"` +Expected: FAIL — the `default` arm of `DrawSeries` draws only a faint border, no red line. + +- [ ] **Step 3: Add the Sparkline case + method** + +In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add a case to the `DrawSeries` switch (before `default:`): + +```csharp + case ChartType.Sparkline: + DrawSparkline(canvas, chart, theme, width, height, antialias); + break; +``` + +Then add the method after `DrawLineOrArea` (it reuses `BuildSeriesPath`): + +```csharp + /// + /// Draws a sparkline: a single small line filling the box (minus a small inset) with no grid, + /// axes, labels, or legend. Honors and + /// ; uses the first series' flat data. + /// + private static void DrawSparkline( + SKCanvas canvas, ChartElement chart, ChartTheme theme, float width, float height, bool antialias) + { + if (chart.Series.Count == 0) + return; + + var data = chart.Series[0].Data; + if (data.Count == 0) + return; + + var inset = MathF.Max(2f, MathF.Min(width, height) * 0.12f); + var left = inset; + var top = inset; + var right = width - inset; + var bottom = height - inset; + if (right <= left || bottom <= top) + return; + + var (dataMin, dataMax) = DataBounds(chart); + var mapper = new ValueMapper(dataMin, dataMax, top, bottom); + + var step = data.Count > 1 ? (right - left) / (data.Count - 1) : 0f; + float X(int i) => data.Count > 1 ? left + (step * i) : (left + right) / 2f; + + var points = new SKPoint[data.Count]; + for (var i = 0; i < data.Count; i++) + points[i] = new SKPoint(X(i), mapper.MapY(data[i])); + + var palette = chart.Palette ?? ChartPalettes.Default; + var color = ColorParser.Parse(palette.ColorAt(0)); + + using var path = BuildSeriesPath(points, chart.Smooth); + using var linePaint = new SKPaint + { + Color = color, + Style = SKPaintStyle.Stroke, + StrokeWidth = MathF.Max(1f, theme.LineWidth - 0.5f), + IsAntialias = antialias, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round + }; + canvas.DrawPath(path, linePaint); + + if (chart.ShowPoints) + { + using var pointPaint = new SKPaint { Color = color, Style = SKPaintStyle.Fill, IsAntialias = antialias }; + for (var i = 0; i < data.Count; i++) + canvas.DrawCircle(X(i), mapper.MapY(data[i]), theme.LineWidth, pointPaint); + } + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSparklineRenderTests"` +Expected: PASS (1 case). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartSparklineRenderTests.cs +git commit --no-gpg-sign -m "feat(skia): render sparkline charts" +``` + +--- + +## Task 8: Renderer — scatter and bubble + +Scatter draws a dot per point; bubble scales the dot radius by `ChartPoint.R`. Both need an X *and* Y `AxisScale` and a local `MapX` mirroring `ValueMapper.MapY`. Grid + Y axis reuse `DrawGridAndYAxis`. + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Test: `tests/FlexRender.Tests/Rendering/ChartScatterRenderTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ChartScatterRenderTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies scatter and bubble draw palette-colored markers. +/// +public sealed class ChartScatterRenderTests +{ + [Fact] + public void Scatter_DrawsColoredDots() + { + var chart = new ChartElement(ChartType.Scatter, new List + { + ChartSeries.FromPoints("cloud", new List + { + ChartPoint.Xy(1d, 10d), ChartPoint.Xy(5d, 25d), ChartPoint.Xy(9d, 18d) + }) + }) + { + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true); + + Assert.True(HasRedPixel(bitmap), "Expected red scatter dots."); + } + + [Fact] + public void Bubble_DrawsLargerMarkersForLargerRadius() + { + var chart = new ChartElement(ChartType.Bubble, new List + { + ChartSeries.FromPoints("b", new List + { + new ChartPoint(2d, 10d, 2d), new ChartPoint(8d, 20d, 30d) + }) + }) + { + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#0000ff" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true); + + Assert.True(CountBluePixels(bitmap) > 50, "Expected substantial blue bubble area."); + } + + private static bool HasRedPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 180 && p.Green < 100 && p.Blue < 100) + return true; + } + return false; + } + + private static int CountBluePixels(SKBitmap bitmap) + { + var count = 0; + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Blue > 150 && p.Red < 120 && p.Green < 120) + count++; + } + return count; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartScatterRenderTests"` +Expected: FAIL — the `default` arm draws only a faint border. + +- [ ] **Step 3: Add the Scatter/Bubble cases + method** + +In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add two cases to `DrawSeries` (before `default:`): + +```csharp + case ChartType.Scatter: + DrawScatterOrBubble(canvas, chart, theme, width, height, typeface, isBubble: false, antialias); + break; + case ChartType.Bubble: + DrawScatterOrBubble(canvas, chart, theme, width, height, typeface, isBubble: true, antialias); + break; +``` + +Then add the method after `DrawSparkline`: + +```csharp + /// + /// Draws a scatter (dots) or bubble (radius-scaled dots) chart. Both axes are scaled to the + /// point data ranges; bubble marker radii are mapped from the largest + /// to a fixed maximum pixel radius so the largest bubble fits comfortably in the plot. + /// + private static void DrawScatterOrBubble( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool isBubble, bool antialias) + { + var (minX, maxX, minY, maxY) = PointBounds(chart); + var xScale = AxisScale.Compute(minX, maxX, targetTicks: 5); + var yScale = AxisScale.Compute(minY, maxY, targetTicks: 5); + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var plot = ChartLayout.ComputePlotArea( + width, height, hasTitle, chart.Legend, + axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f); + + DrawGridAndYAxis(canvas, theme, plot, yScale, typeface, antialias); + + var yMapper = new ValueMapper(yScale.Min, yScale.Max, plot.Top, plot.Bottom); + + // X mapping mirrors ValueMapper.MapY but along the horizontal axis (min -> left, max -> right). + var plotLeft = plot.Left; + var plotWidth = plot.Width; + var xMin = xScale.Min; + var xSpan = xScale.Max - xScale.Min; + float MapX(double value) => xSpan <= 0d + ? plotLeft + (plotWidth / 2f) + : plotLeft + (float)(((value - xMin) / xSpan) * plotWidth); + + // Determine the largest radius for bubble scaling. + var maxR = 0d; + if (isBubble) + { + foreach (var s in chart.Series) + foreach (var p in s.Points) + if (p.R > maxR) maxR = p.R; + } + var maxPixelRadius = MathF.Max(4f, MathF.Min(plot.Width, plot.Height) * 0.12f); + const float scatterRadius = 4f; + + var palette = chart.Palette ?? ChartPalettes.Default; + + for (var si = 0; si < chart.Series.Count; si++) + { + var pts = chart.Series[si].Points; + if (pts.Count == 0) + continue; + + var color = ColorParser.Parse(palette.ColorAt(si)); + using var paint = new SKPaint + { + Color = isBubble ? color.WithAlpha(150) : color, + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + + foreach (var p in pts) + { + var cx = MapX(p.X); + var cy = yMapper.MapY(p.Y); + float radius; + if (isBubble && maxR > 0d) + radius = MathF.Max(2f, (float)(p.R / maxR) * maxPixelRadius); + else + radius = scatterRadius; + canvas.DrawCircle(cx, cy, radius, paint); + } + } + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartScatterRenderTests"` +Expected: PASS (2 cases). + +- [ ] **Step 5: Commit** + +If Task 6 was committed standalone: + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartScatterRenderTests.cs +git commit --no-gpg-sign -m "feat(skia): render scatter and bubble charts" +``` + +If Task 6 was folded in here (build had flagged `PointBounds` unused), the same `git add`/`commit` covers both. + +--- + +## Task 9: Renderer — gauge (arc/dial) + +A gauge draws a 270-degree background arc and a colored value arc from the start angle, plus a centered value/label text. `value` is clamped into `[0, max]`; `max` defaults to 100 when null. + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Test: `tests/FlexRender.Tests/Rendering/ChartGaugeRenderTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ChartGaugeRenderTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies the gauge draws a colored value arc. +/// +public sealed class ChartGaugeRenderTests +{ + [Fact] + public void Gauge_DrawsColoredValueArc() + { + var chart = new ChartElement(ChartType.Gauge, new List()) + { + Value = 70d, + Max = 100d, + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(240, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 240f, 200f, typeface: null, antialias: true); + + Assert.True(HasRedPixel(bitmap), "Expected a red gauge arc."); + } + + [Fact] + public void Gauge_MissingValue_DrawsNoDataPlaceholder() + { + var chart = new ChartElement(ChartType.Gauge, new List()) + { + Legend = LegendPosition.None, + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(240, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 240f, 200f, typeface: null, antialias: true); + + Assert.True(HasNonWhitePixel(bitmap), "Expected the no-data border to draw."); + } + + private static bool HasRedPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 180 && p.Green < 100 && p.Blue < 100) + return true; + } + return false; + } + + private static bool HasNonWhitePixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red < 240 || p.Green < 240 || p.Blue < 240) + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartGaugeRenderTests"` +Expected: FAIL — `Gauge_DrawsColoredValueArc` finds no red (the `default` border arm draws axis-color gray only). The no-data case already passes via `HasAnyData` returning false for a null value (Task 6), so only the arc case fails. + +- [ ] **Step 3: Add the Gauge case + method** + +In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add a case to `DrawSeries` (before `default:`): + +```csharp + case ChartType.Gauge: + DrawGauge(canvas, chart, theme, width, height, typeface, antialias); + break; +``` + +Then add the method after `DrawScatterOrBubble`: + +```csharp + /// + /// Draws a 270-degree gauge dial: a faint background arc plus a colored value arc proportional + /// to value/max, with the value and optional caption centered below the dial. + /// + private static void DrawGauge( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + var value = chart.Value ?? 0d; + var max = chart.Max is > 0d ? chart.Max.Value : 100d; + var fraction = max <= 0d ? 0d : Math.Clamp(value / max, 0d, 1d); + + // Geometry: a 270-degree sweep starting at 135 degrees (bottom-left), open at the bottom. + const float startAngle = 135f; + const float fullSweep = 270f; + + var diameter = MathF.Min(width, height) * 0.7f; + var radius = diameter / 2f; + var cx = width / 2f; + var cy = height / 2f; + var thickness = MathF.Max(6f, radius * 0.22f); + + var bounds = new SKRect(cx - radius, cy - radius, cx + radius, cy + radius); + var palette = chart.Palette ?? ChartPalettes.Default; + var valueColor = ColorParser.Parse(palette.ColorAt(0)); + + using var trackPaint = new SKPaint + { + Color = ColorParser.Parse(theme.GridColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = thickness, + StrokeCap = SKStrokeCap.Round, + IsAntialias = antialias + }; + using var track = new SKPath(); + track.AddArc(bounds, startAngle, fullSweep); + canvas.DrawPath(track, trackPaint); + + if (fraction > 0d) + { + using var valuePaint = new SKPaint + { + Color = valueColor, + Style = SKPaintStyle.Stroke, + StrokeWidth = thickness, + StrokeCap = SKStrokeCap.Round, + IsAntialias = antialias + }; + using var valueArc = new SKPath(); + valueArc.AddArc(bounds, startAngle, (float)(fullSweep * fraction)); + canvas.DrawPath(valueArc, valuePaint); + } + + DrawIndicatorText(canvas, chart, theme, value, max, cx, cy, typeface, antialias); + } + + /// + /// Draws the centered numeric value (and optional caption) for gauge/progress charts when a + /// typeface is available. + /// + private static void DrawIndicatorText( + SKCanvas canvas, ChartElement chart, ChartTheme theme, double value, double max, + float cx, float cy, SKTypeface? typeface, bool antialias) + { + if (typeface is null) + return; + + var valueText = FormatTick(value); + using var valueFont = new SKFont(typeface, theme.TitleSize); + using var valuePaint = new SKPaint { Color = ColorParser.Parse(theme.TitleColor), IsAntialias = antialias }; + var vw = valueFont.MeasureText(valueText); + canvas.DrawText(valueText, cx - (vw / 2f), cy + (theme.TitleSize / 3f), SKTextAlign.Left, valueFont, valuePaint); + + if (string.IsNullOrEmpty(chart.ValueLabel)) + return; + + using var capFont = new SKFont(typeface, theme.LabelSize); + using var capPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + var cw = capFont.MeasureText(chart.ValueLabel); + canvas.DrawText(chart.ValueLabel, cx - (cw / 2f), cy + theme.TitleSize, SKTextAlign.Left, capFont, capPaint); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartGaugeRenderTests"` +Expected: PASS (2 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartGaugeRenderTests.cs +git commit --no-gpg-sign -m "feat(skia): render gauge charts" +``` + +--- + +## Task 10: Renderer — progress (ring) + +Progress draws a full-circle faint track plus a colored value arc starting at 12 o'clock, with centered text. It reuses `DrawIndicatorText` from Task 9. + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Test: `tests/FlexRender.Tests/Rendering/ChartProgressRenderTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ChartProgressRenderTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies the progress ring draws a colored value arc. +/// +public sealed class ChartProgressRenderTests +{ + [Fact] + public void Progress_DrawsColoredRingArc() + { + var chart = new ChartElement(ChartType.Progress, new List()) + { + Value = 40d, + Max = 100d, + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: true); + + Assert.True(HasRedPixel(bitmap), "Expected a red progress arc."); + } + + private static bool HasRedPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 180 && p.Green < 100 && p.Blue < 100) + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartProgressRenderTests"` +Expected: FAIL — no red (the `default` border arm draws axis-color gray only). + +- [ ] **Step 3: Add the Progress case + method** + +In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add a case to `DrawSeries` (before `default:`): + +```csharp + case ChartType.Progress: + DrawProgress(canvas, chart, theme, width, height, typeface, antialias); + break; +``` + +Then add the method after `DrawIndicatorText`: + +```csharp + /// + /// Draws a progress ring: a full-circle faint track plus a colored value arc starting at 12 + /// o'clock and sweeping clockwise proportional to value/max, with centered value text. + /// + private static void DrawProgress( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + var value = chart.Value ?? 0d; + var max = chart.Max is > 0d ? chart.Max.Value : 100d; + var fraction = max <= 0d ? 0d : Math.Clamp(value / max, 0d, 1d); + + var diameter = MathF.Min(width, height) * 0.7f; + var radius = diameter / 2f; + var cx = width / 2f; + var cy = height / 2f; + var thickness = MathF.Max(6f, radius * 0.2f); + + var bounds = new SKRect(cx - radius, cy - radius, cx + radius, cy + radius); + var palette = chart.Palette ?? ChartPalettes.Default; + var valueColor = ColorParser.Parse(palette.ColorAt(0)); + + using var trackPaint = new SKPaint + { + Color = ColorParser.Parse(theme.GridColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = thickness, + IsAntialias = antialias + }; + canvas.DrawOval(bounds, trackPaint); + + if (fraction > 0d) + { + using var valuePaint = new SKPaint + { + Color = valueColor, + Style = SKPaintStyle.Stroke, + StrokeWidth = thickness, + StrokeCap = SKStrokeCap.Round, + IsAntialias = antialias + }; + using var valueArc = new SKPath(); + // Start at 12 o'clock (-90 degrees) and sweep clockwise. + valueArc.AddArc(bounds, -90f, (float)(360d * fraction)); + canvas.DrawPath(valueArc, valuePaint); + } + + DrawIndicatorText(canvas, chart, theme, value, max, cx, cy, typeface, antialias); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartProgressRenderTests"` +Expected: PASS (1 case). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartProgressRenderTests.cs +git commit --no-gpg-sign -m "feat(skia): render progress ring charts" +``` + +--- + +## Task 11: End-to-end smoke tests via the YAML pipeline + +Verify each new type renders through the full parse → measure → render path (not just direct `ChartRenderer.Draw`). + +**Files:** +- Modify: `tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs` + +- [ ] **Step 1: Add the failing smoke tests** + +Append these methods inside the `ChartRenderSmokeTests` class (before its closing brace). They reuse the private `Render`/`HasNonBackgroundPixel` helpers already in the file: + +```csharp + [Fact] + public async Task ScatterChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 320 + height: 240 + background: "#ffffff" + layout: + - type: chart + chart-type: scatter + width: 320 + height: 240 + series: + - data: [[1, 10], [4, 25], [7, 18], [9, 30]] + legend: none + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected scatter to draw."); + } + + [Fact] + public async Task BubbleChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 320 + height: 240 + background: "#ffffff" + layout: + - type: chart + chart-type: bubble + width: 320 + height: 240 + series: + - data: [[1, 10, 5], [5, 25, 18], [8, 15, 9]] + legend: none + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected bubble to draw."); + } + + [Fact] + public async Task GaugeChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 240 + height: 200 + background: "#ffffff" + layout: + - type: chart + chart-type: gauge + width: 240 + height: 200 + value: 65 + max: 100 + label: CPU + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected gauge to draw."); + } + + [Fact] + public async Task ProgressChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 200 + height: 200 + background: "#ffffff" + layout: + - type: chart + chart-type: progress + width: 200 + height: 200 + value: 45 + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected progress to draw."); + } + + [Fact] + public async Task SparklineChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 160 + height: 50 + background: "#ffffff" + layout: + - type: chart + chart-type: sparkline + width: 160 + height: 50 + series: + - data: [3, 8, 4, 10, 6, 9] + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected sparkline to draw."); + } +``` + +- [ ] **Step 2: Run the smoke tests** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRenderSmokeTests"` +Expected: PASS (all cases, including the 2 pre-existing Phase-2 cases). + +- [ ] **Step 3: Commit** + +```bash +git add tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs +git commit --no-gpg-sign -m "test(skia): end-to-end smoke tests for Phase-3 chart types" +``` + +--- + +## Task 12: Snapshot goldens for the five new types + +**Files:** +- Modify: `tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs` +- Create (generated): `tests/FlexRender.Tests/Snapshots/golden/chart_scatter_light.png`, `chart_bubble_light.png`, `chart_gauge_dark.png`, `chart_progress_light.png`, `chart_sparkline_light.png` + +- [ ] **Step 1: Add the snapshot tests** + +Append these methods inside the `ChartSnapshotTests` class (before its closing brace): + +```csharp + [Fact] + public async Task ScatterChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 480 + background: "#ffffff" + layout: + - type: chart + chart-type: scatter + width: 480 + height: 320 + series: + - label: cloud + data: [[1, 12], [3, 30], [5, 22], [7, 48], [9, 35]] + legend: none + palette: ocean + """); + await AssertSnapshot("chart_scatter_light", template, new ObjectValue()); + } + + [Fact] + public async Task BubbleChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 480 + background: "#ffffff" + layout: + - type: chart + chart-type: bubble + width: 480 + height: 320 + series: + - data: [[1, 12, 6], [3, 30, 18], [5, 22, 10], [7, 48, 24]] + legend: none + palette: sunset + """); + await AssertSnapshot("chart_bubble_light", template, new ObjectValue()); + } + + [Fact] + public async Task GaugeChart_Dark() + { + var template = Parser.Parse(""" + canvas: + width: 280 + background: "#1e1e1e" + layout: + - type: chart + chart-type: gauge + width: 280 + height: 220 + value: 72 + max: 100 + label: CPU + theme: dark + palette: vivid + """); + await AssertSnapshot("chart_gauge_dark", template, new ObjectValue()); + } + + [Fact] + public async Task ProgressChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 240 + background: "#ffffff" + layout: + - type: chart + chart-type: progress + width: 240 + height: 240 + value: 64 + max: 100 + label: Disk + palette: forest + """); + await AssertSnapshot("chart_progress_light", template, new ObjectValue()); + } + + [Fact] + public async Task SparklineChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 200 + background: "#ffffff" + layout: + - type: chart + chart-type: sparkline + width: 200 + height: 50 + smooth: true + series: + - data: [3, 8, 4, 10, 6, 9, 5, 11] + """); + await AssertSnapshot("chart_sparkline_light", template, new ObjectValue()); + } +``` + +- [ ] **Step 2: Run the snapshot tests to confirm they fail (no golden yet)** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"` +Expected: FAIL for the five new tests with "Golden image not found" (Phase-2 snapshots still pass). + +- [ ] **Step 3: Generate the goldens** + +Run: `UPDATE_SNAPSHOTS=true dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"` +Expected: PASS (goldens written). Confirm five new PNGs exist under `tests/FlexRender.Tests/Snapshots/golden/`. + +- [ ] **Step 4: Re-run without update to confirm they match** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"` +Expected: PASS (all, including the five new goldens). + +- [ ] **Step 5: Commit** + +```bash +git add tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs tests/FlexRender.Tests/Snapshots/golden/chart_scatter_light.png tests/FlexRender.Tests/Snapshots/golden/chart_bubble_light.png tests/FlexRender.Tests/Snapshots/golden/chart_gauge_dark.png tests/FlexRender.Tests/Snapshots/golden/chart_progress_light.png tests/FlexRender.Tests/Snapshots/golden/chart_sparkline_light.png +git commit --no-gpg-sign -m "test(charts): snapshot goldens for scatter/bubble/gauge/progress/sparkline" +``` + +--- + +## Task 13: Full build + full test gate + +**Files:** none (verification only) + +- [ ] **Step 1: Clean build** + +Run: `dotnet build FlexRender.slnx` +Expected: BUILD SUCCEEDED, zero warnings (TreatWarningsAsErrors). If CA1859 / unused-using / unused-param errors appear, fix them in the offending file and rebuild. + +- [ ] **Step 2: Full test suite** + +Run: `dotnet test FlexRender.slnx --framework net10.0` +Expected: PASS — all tests green (Phase-1, Phase-2, and the new Phase-3 tests). + +- [ ] **Step 3: Commit (only if a fix was needed in Step 1)** + +```bash +git add -A +git commit --no-gpg-sign -m "chore(charts): resolve analyzer warnings for Phase-3 chart types" +``` + +If no fix was needed, skip this commit. + +--- + +## Task 14: Docs — Playground schema + autocomplete + +**Files:** +- Modify: `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json` +- Modify: `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` + +- [ ] **Step 1: Extend the schema chart-type enum** + +In `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`, find the chart `"chart-type"` block (the `"enum": ["bar", "line", "area", "pie", "donut"]` near line 883) and replace that enum line with: + +```json + "enum": ["bar", "line", "area", "pie", "donut", "scatter", "bubble", "gauge", "progress", "sparkline"] +``` + +- [ ] **Step 2: Update the series.data description for tuples** + +In the same chart definition, replace the `"data"` description string inside `series.items.properties.data` with: + +```json + "description": "Flat number array (bar/line/area/pie/donut/sparkline), OR an array of [x, y] / [x, y, r] tuples (scatter/bubble), OR a {{ expr }} string resolving to a number array.", +``` + +- [ ] **Step 3: Add value/max/label to the chart schema properties** + +In the same chart definition `properties` block, after the `"labels"` property block, add (mind the trailing comma on the preceding `"labels"` block's closing brace): + +```json + "value": { + "description": "Gauge/progress only. The indicator value.", + "type": "number" + }, + "max": { + "description": "Gauge/progress only. The indicator maximum (default 100).", + "type": "number" + }, + "label": { + "description": "Gauge/progress only. Centered caption under the value.", + "type": "string" + } +``` + +- [ ] **Step 4: Update autocomplete if it enumerates chart-type values** + +Inspect `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` for any literal list of chart-type values (`bar`, `donut`, etc.). If such a list exists, add `scatter`, `bubble`, `gauge`, `progress`, `sparkline`. If the file derives completions from the schema (no hardcoded chart-type list), no edit is needed — note that in the commit body. + +Verify with: `grep -n "donut" src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` (no result means schema-driven; nothing to change). + +- [ ] **Step 5: Validate the JSON parses** + +Run: `node -e "JSON.parse(require('fs').readFileSync('src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json','utf8')); console.log('ok')"` +Expected: prints `ok`. + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs +git commit --no-gpg-sign -m "docs(playground): add Phase-3 chart types to schema and autocomplete" +``` + +--- + +## Task 15: Docs — wiki Element-Reference + Visual-Reference + +**Files:** +- Modify: `docs/wiki/Element-Reference.md` +- Modify: `docs/wiki/Visual-Reference.md` + +- [ ] **Step 1: Update the chart intro + chart-type row in Element-Reference** + +In `docs/wiki/Element-Reference.md`, in the `## chart` section: + +1. Update the intro sentence (around line 1462) to mention the new types. Replace the existing chart-type list "renders `bar`, `line`, `area`, `pie`, and `donut` charts" with: + +``` +renders `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, and `sparkline` charts +``` + +2. Update the `ChartType` property-table row (the `| ChartType | chart-type | ... | bar, line, area, pie, donut |` row near line 1480) to: + +``` +| ChartType | `chart-type` | string | `bar` | bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline | No | Chart kind. | +``` + +3. Add three new rows to the chart property table immediately after the row documenting `labels` (the pie/donut label-mode row). Match the existing table's column layout exactly: + +``` +| Value | `value` | number | none | Any number | Gauge/progress only | Indicator value. | +| Max | `max` | number | `100` | Any number | Gauge/progress only | Indicator maximum. | +| ValueLabel | `label` | string | none | Any string | Gauge/progress only | Centered caption under the value. | +``` + +4. Add a short note paragraph after the chart property table documenting tuple data: + +``` +> **Scatter / bubble data:** supply `series[].data` as an array of tuples — `[[x, y], ...]` for `scatter` and `[[x, y, r], ...]` for `bubble`, where `r` sizes the bubble. Tuple data is inline-only in this release (expression-bound tuples are not yet supported). Sparkline uses a plain numeric `data` array and honors `smooth`/`points`. +``` + +- [ ] **Step 2: Add examples to Visual-Reference** + +In `docs/wiki/Visual-Reference.md`, find the chart section and append five compact examples (place them after the existing donut/pie chart example block): + +``` +### Scatter + +```yaml +- type: chart + chart-type: scatter + width: 480 + height: 320 + series: + - data: [[1, 12], [3, 30], [5, 22], [7, 48], [9, 35]] + legend: none + palette: ocean +``` + +### Bubble + +```yaml +- type: chart + chart-type: bubble + width: 480 + height: 320 + series: + - data: [[1, 12, 6], [3, 30, 18], [5, 22, 10], [7, 48, 24]] + legend: none + palette: sunset +``` + +### Gauge + +```yaml +- type: chart + chart-type: gauge + width: 280 + height: 220 + value: 72 + max: 100 + label: CPU +``` + +### Progress + +```yaml +- type: chart + chart-type: progress + width: 240 + height: 240 + value: 64 + label: Disk +``` + +### Sparkline + +```yaml +- type: chart + chart-type: sparkline + width: 200 + height: 50 + smooth: true + series: + - data: [3, 8, 4, 10, 6, 9, 5, 11] +``` +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/wiki/Element-Reference.md docs/wiki/Visual-Reference.md +git commit --no-gpg-sign -m "docs(wiki): document Phase-3 chart types" +``` + +--- + +## Task 16: Docs — llms.txt + llms-full.txt + +**Files:** +- Modify: `llms.txt` +- Modify: `llms-full.txt` + +- [ ] **Step 1: Update the chart-type listing in both files** + +In both `llms.txt` and `llms-full.txt`, find the text that enumerates chart-types (search for `donut`) and update the list to include the new types. Replace the chart-type enumeration (e.g. `bar, line, area, pie, donut`) with: + +``` +bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline +``` + +- [ ] **Step 2: Add a Phase-3 usage note in llms-full.txt** + +In `llms-full.txt`, in the chart section, add this note after the existing chart-type description: + +``` +Scatter and bubble take tuple data: `data: [[x, y], ...]` (scatter) or `data: [[x, y, r], ...]` (bubble, r = bubble radius). Gauge and progress are single-value indicators using `value`, `max` (default 100), and `label` (centered caption); gauge draws a 270-degree dial and progress draws a ring. Sparkline is a tiny inline line chart (no axes/labels/legend) using a plain numeric `data` array, honoring `smooth` and `points`. +``` + +- [ ] **Step 3: Verify the strings landed** + +Run: `grep -c "sparkline" llms.txt llms-full.txt` +Expected: a non-zero count for each file. + +- [ ] **Step 4: Commit** + +```bash +git add llms.txt llms-full.txt +git commit --no-gpg-sign -m "docs(llms): document Phase-3 chart types" +``` + +--- + +## Task 17: Final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Clean build** + +Run: `dotnet build FlexRender.slnx` +Expected: BUILD SUCCEEDED, zero warnings. + +- [ ] **Step 2: Full test suite (authoritative framework)** + +Run: `dotnet test FlexRender.slnx --framework net10.0` +Expected: PASS — every test green. + +- [ ] **Step 3: Confirm git state is clean** + +Run: `git status` +Expected: working tree clean (all Phase-3 work committed). The branch remains `feature/charts-and-shapes`; do NOT merge to `main`. + +--- + +## Self-review + +**Spec coverage (each Phase-3 type → task):** +- `scatter` — type enum (Task 1), `Points` rep (Task 2), tuple parsing (Task 4), X/Y-scale rendering (Task 8), smoke (Task 11), snapshot (Task 12). +- `bubble` — same chain; radius via third tuple element (Task 4) and radius-scaled markers (Task 8). +- `gauge` — `Value`/`Max`/`ValueLabel` (Task 3), parsing (Task 5), 270° dial rendering + no-data on missing value (Task 9), smoke (Task 11), snapshot (Task 12). +- `progress` — same props/parsing; ring rendering (Task 10), smoke (Task 11), snapshot (Task 12). +- `sparkline` — type enum (Task 1), no-chrome line reusing `BuildSeriesPath` honoring `smooth`/`points` (Task 7), smoke (Task 11), snapshot (Task 12). +- Error handling: unknown-prop typo suggestions covered by existing `KnownProperties` validation + new props registered (Task 5, test in Task 5); malformed tuples (arity/non-numeric/non-finite) named per series (Task 4); gauge/progress missing `value` → no-data placeholder (Task 6 `HasAnyData` + Task 9 test). +- Docs: schema + autocomplete (Task 14), wiki (Task 15), llms (Task 16). +- Resource limits reused, not weakened: scatter/bubble points counted against `MaxDataPointsPerSeries` via the existing `dataSeq.Children.Count` check in `ParseOneSeries` (Task 4) before branching to tuple parsing. + +**Placeholder scan:** No `TBD`/`TODO`/"add appropriate"/"similar to Task N" — every code step shows complete, compilable code. The one conditional (Task 6 standalone commit vs fold-into-Task-8) is fully specified with the exact condition and both paths. + +**Type consistency:** +- `ChartPoint(double X, double Y, double R)` + `ChartPoint.Xy(x, y)` — defined Task 2, used identically in Tasks 4 (`new ChartPoint(x, y, r)`), 8 (`p.X/p.Y/p.R`), and tests. +- `ChartSeries.FromPoints(string?, IReadOnlyList)` and `.Points` — defined Task 2, used in Tasks 4, 8. +- `ChartElement.Value`/`Max`/`ValueLabel` (all nullable; `Value`/`Max` are `double?`, `ValueLabel` is `string?`) — defined Task 3, set in Task 5 (`GetDoubleValue` returns `double?`, `GetStringValue` returns `string?`), consumed in Tasks 9/10 (`chart.Value ?? 0d`, `chart.Max is > 0d ? ... : 100d`). +- `PointBounds` returns `(double MinX, double MaxX, double MinY, double MaxY)` — defined Task 6, deconstructed identically in Task 8. +- `DrawIndicatorText(canvas, chart, theme, value, max, cx, cy, typeface, antialias)` — defined Task 9, called with the same argument order in Task 10. +- `DrawScatterOrBubble(..., bool isBubble, ...)` and `DrawGauge`/`DrawProgress`/`DrawSparkline` signatures match their `DrawSeries` call sites in Tasks 7–10. +- `ParseOneSeries(YamlMappingNode, ChartType, int)` and `ParseSeries(YamlMappingNode, ChartType, int, int)` — both updated together in Task 4; the `ParseChartElement` call site updated in the same task. From 52a10dc80678ec75eed83dfb91330e245f3f5f1c Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:55:30 +0400 Subject: [PATCH 062/119] feat(charts): add scatter/bubble/gauge/progress/sparkline chart types --- src/FlexRender.Core/Charts/ChartEnums.cs | 17 +++++++++- .../Charts/ChartTypePhase3Tests.cs | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/FlexRender.Tests/Charts/ChartTypePhase3Tests.cs diff --git a/src/FlexRender.Core/Charts/ChartEnums.cs b/src/FlexRender.Core/Charts/ChartEnums.cs index 471d2e2..cbb91ed 100644 --- a/src/FlexRender.Core/Charts/ChartEnums.cs +++ b/src/FlexRender.Core/Charts/ChartEnums.cs @@ -18,7 +18,22 @@ public enum ChartType Pie, /// Donut (ring) chart. - Donut + Donut, + + /// XY scatter plot ([x, y] tuples). + Scatter, + + /// Bubble plot ([x, y, r] tuples; the third value sizes the bubble). + Bubble, + + /// Single-value arc/dial gauge. + Gauge, + + /// Single-value progress ring. + Progress, + + /// Tiny inline line chart with no axes, labels, or legend. + Sparkline } /// diff --git a/tests/FlexRender.Tests/Charts/ChartTypePhase3Tests.cs b/tests/FlexRender.Tests/Charts/ChartTypePhase3Tests.cs new file mode 100644 index 0000000..a87de8d --- /dev/null +++ b/tests/FlexRender.Tests/Charts/ChartTypePhase3Tests.cs @@ -0,0 +1,33 @@ +using System; +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Sanity tests for the Phase-3 chart type enum members. +/// +public sealed class ChartTypePhase3Tests +{ + [Theory] + [InlineData("Scatter")] + [InlineData("Bubble")] + [InlineData("Gauge")] + [InlineData("Progress")] + [InlineData("Sparkline")] + public void ChartType_HasAllPhase3Members(string name) + { + Assert.True(Enum.TryParse(name, out _)); + } + + [Theory] + [InlineData("Bar")] + [InlineData("Line")] + [InlineData("Area")] + [InlineData("Pie")] + [InlineData("Donut")] + public void ChartType_StillHasPhase2Members(string name) + { + Assert.True(Enum.TryParse(name, out _)); + } +} From 5ae5e819a22ec95a6a8cf601b17cac968a9a44dc Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:56:50 +0400 Subject: [PATCH 063/119] feat(charts): add ChartPoint tuple representation to ChartSeries --- src/FlexRender.Core/Charts/ChartPoint.cs | 19 +++++++++ .../Parsing/Ast/ChartSeries.cs | 30 ++++++++++++-- .../Charts/ChartPointTests.cs | 28 +++++++++++++ .../Parsing/Ast/ChartSeriesPointsTests.cs | 40 +++++++++++++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 src/FlexRender.Core/Charts/ChartPoint.cs create mode 100644 tests/FlexRender.Tests/Charts/ChartPointTests.cs create mode 100644 tests/FlexRender.Tests/Parsing/Ast/ChartSeriesPointsTests.cs diff --git a/src/FlexRender.Core/Charts/ChartPoint.cs b/src/FlexRender.Core/Charts/ChartPoint.cs new file mode 100644 index 0000000..8b747ed --- /dev/null +++ b/src/FlexRender.Core/Charts/ChartPoint.cs @@ -0,0 +1,19 @@ +namespace FlexRender.Charts; + +/// +/// A single XY data point with an optional radius, used by scatter ([x, y]) and bubble +/// ([x, y, r]) series. Renderer-agnostic; is zero for scatter points. +/// +/// The X coordinate in data space. +/// The Y coordinate in data space. +/// The bubble radius in data space (zero for scatter points). +public readonly record struct ChartPoint(double X, double Y, double R) +{ + /// + /// Creates a point with no radius (scatter). + /// + /// The X coordinate. + /// The Y coordinate. + /// A with set to zero. + public static ChartPoint Xy(double x, double y) => new(x, y, 0d); +} diff --git a/src/FlexRender.Core/Parsing/Ast/ChartSeries.cs b/src/FlexRender.Core/Parsing/Ast/ChartSeries.cs index 038c033..f641b48 100644 --- a/src/FlexRender.Core/Parsing/Ast/ChartSeries.cs +++ b/src/FlexRender.Core/Parsing/Ast/ChartSeries.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using FlexRender.Charts; namespace FlexRender.Parsing.Ast; @@ -11,12 +12,14 @@ namespace FlexRender.Parsing.Ast; public sealed class ChartSeries { private static readonly IReadOnlyList Empty = Array.Empty(); + private static readonly IReadOnlyList EmptyPoints = Array.Empty(); - private ChartSeries(string? label, string? dataExpression, IReadOnlyList data) + private ChartSeries(string? label, string? dataExpression, IReadOnlyList data, IReadOnlyList points) { Label = label; DataExpression = dataExpression; Data = data; + Points = points; } /// Gets the optional series label shown in the legend. @@ -31,6 +34,12 @@ private ChartSeries(string? label, string? dataExpression, IReadOnlyList /// Gets the resolved numeric values. Empty until a bound expression is resolved. public IReadOnlyList Data { get; } + /// + /// Gets the resolved XY(R) tuple points for scatter/bubble series. Empty for flat numeric + /// series (which use instead). + /// + public IReadOnlyList Points { get; } + /// /// Creates a series with inline numeric data. /// @@ -41,7 +50,7 @@ private ChartSeries(string? label, string? dataExpression, IReadOnlyList public static ChartSeries FromInline(string? label, IReadOnlyList data) { ArgumentNullException.ThrowIfNull(data); - return new ChartSeries(label, dataExpression: null, data); + return new ChartSeries(label, dataExpression: null, data, EmptyPoints); } /// @@ -53,7 +62,20 @@ public static ChartSeries FromInline(string? label, IReadOnlyList data) public static ChartSeries FromExpression(string? label, string dataExpression) { ArgumentNullException.ThrowIfNull(dataExpression); - return new ChartSeries(label, dataExpression, Empty); + return new ChartSeries(label, dataExpression, Empty, EmptyPoints); + } + + /// + /// Creates a series with inline XY(R) tuple points (scatter/bubble). + /// + /// The optional legend label. + /// The tuple points. + /// A new whose is empty. + /// Thrown when is null. + public static ChartSeries FromPoints(string? label, IReadOnlyList points) + { + ArgumentNullException.ThrowIfNull(points); + return new ChartSeries(label, dataExpression: null, Empty, points); } /// @@ -66,6 +88,6 @@ public static ChartSeries FromExpression(string? label, string dataExpression) public ChartSeries WithData(IReadOnlyList data) { ArgumentNullException.ThrowIfNull(data); - return new ChartSeries(Label, DataExpression, data); + return new ChartSeries(Label, DataExpression, data, EmptyPoints); } } diff --git a/tests/FlexRender.Tests/Charts/ChartPointTests.cs b/tests/FlexRender.Tests/Charts/ChartPointTests.cs new file mode 100644 index 0000000..d57c727 --- /dev/null +++ b/tests/FlexRender.Tests/Charts/ChartPointTests.cs @@ -0,0 +1,28 @@ +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for the XY(R) tuple struct. +/// +public sealed class ChartPointTests +{ + [Fact] + public void Constructor_StoresXYR() + { + var p = new ChartPoint(1.5d, 2.5d, 3.5d); + Assert.Equal(1.5d, p.X); + Assert.Equal(2.5d, p.Y); + Assert.Equal(3.5d, p.R); + } + + [Fact] + public void Xy_DefaultsRadiusToZero() + { + var p = ChartPoint.Xy(4d, 5d); + Assert.Equal(4d, p.X); + Assert.Equal(5d, p.Y); + Assert.Equal(0d, p.R); + } +} diff --git a/tests/FlexRender.Tests/Parsing/Ast/ChartSeriesPointsTests.cs b/tests/FlexRender.Tests/Parsing/Ast/ChartSeriesPointsTests.cs new file mode 100644 index 0000000..034cf9f --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Ast/ChartSeriesPointsTests.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the tuple-point representation on . +/// +public sealed class ChartSeriesPointsTests +{ + [Fact] + public void FromPoints_StoresLabelAndPoints_DataEmpty() + { + var points = new List { ChartPoint.Xy(1d, 2d), ChartPoint.Xy(3d, 4d) }; + var series = ChartSeries.FromPoints("xy", points); + + Assert.Equal("xy", series.Label); + Assert.Equal(2, series.Points.Count); + Assert.Equal(1d, series.Points[0].X); + Assert.Empty(series.Data); + Assert.Null(series.DataExpression); + } + + [Fact] + public void FromInline_LeavesPointsEmpty() + { + var series = ChartSeries.FromInline("flat", new[] { 1d, 2d, 3d }); + Assert.Empty(series.Points); + Assert.Equal(3, series.Data.Count); + } + + [Fact] + public void FromPoints_NullPoints_Throws() + { + Assert.Throws(() => ChartSeries.FromPoints("x", null!)); + } +} From ed7704b8add37121158b220e1be77adfc17122ce Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 21:57:37 +0400 Subject: [PATCH 064/119] feat(ast): add Value/Max/ValueLabel to ChartElement for gauge and progress --- .../Parsing/Ast/ChartElement.cs | 14 ++++++- .../Parsing/Ast/ChartElementGaugeTests.cs | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/FlexRender.Tests/Parsing/Ast/ChartElementGaugeTests.cs diff --git a/src/FlexRender.Core/Parsing/Ast/ChartElement.cs b/src/FlexRender.Core/Parsing/Ast/ChartElement.cs index d278926..930cbc1 100644 --- a/src/FlexRender.Core/Parsing/Ast/ChartElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/ChartElement.cs @@ -65,6 +65,15 @@ public ChartElement(ChartType chartType, IReadOnlyList series) /// Gets or sets how pie/donut slice labels are rendered. public PieLabelMode PieLabels { get; set; } = PieLabelMode.Percent; + /// Gets or sets the indicator value for gauge/progress charts. Null renders a "no data" placeholder. + public double? Value { get; set; } + + /// Gets or sets the indicator maximum for gauge/progress charts. Null defaults to 100. + public double? Max { get; set; } + + /// Gets or sets the centered caption for gauge/progress charts (distinct from ). + public string? ValueLabel { get; set; } + /// public override TemplateElement CloneWithSubstitution(Func substitutor) { @@ -81,7 +90,10 @@ public override TemplateElement CloneWithSubstitution(Func sub Stacked = Stacked, Smooth = Smooth, ShowPoints = ShowPoints, - PieLabels = PieLabels + PieLabels = PieLabels, + Value = Value, + Max = Max, + ValueLabel = ValueLabel }; CopyBasePropertiesTo(clone, substitutor); return clone; diff --git a/tests/FlexRender.Tests/Parsing/Ast/ChartElementGaugeTests.cs b/tests/FlexRender.Tests/Parsing/Ast/ChartElementGaugeTests.cs new file mode 100644 index 0000000..74b4b38 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Ast/ChartElementGaugeTests.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the gauge/progress single-value properties on . +/// +public sealed class ChartElementGaugeTests +{ + [Fact] + public void Defaults_ValueMaxLabelAreNull() + { + var chart = new ChartElement(ChartType.Gauge, new List()); + Assert.Null(chart.Value); + Assert.Null(chart.Max); + Assert.Null(chart.ValueLabel); + } + + [Fact] + public void CloneWithSubstitution_PreservesValueMaxLabel() + { + var chart = new ChartElement(ChartType.Progress, new List()) + { + Value = 72d, + Max = 100d, + ValueLabel = "CPU" + }; + + var clone = (ChartElement)chart.CloneWithSubstitution(s => s); + + Assert.Equal(72d, clone.Value); + Assert.Equal(100d, clone.Max); + Assert.Equal("CPU", clone.ValueLabel); + } +} From 2defff2ed8a6fcadd9d3932dc5940a60733b06b5 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:02:21 +0400 Subject: [PATCH 065/119] feat(yaml): parse scatter/bubble tuple series data Scatter/bubble series accept array-of-arrays data ([[x,y],...] or [[x,y,r],...]) parsed into ChartPoint tuples via ChartSeries.FromPoints. Flat scalar arrays keep the existing FromInline path. Tuple arity, numeric and finite values are validated with the series named in the error. Scatter is strictly 2-element; bubble allows an optional radius. The chart-type error message now lists scatter, bubble, gauge, progress and sparkline. --- src/FlexRender.Yaml/Parsing/ChartParsers.cs | 77 ++++++++++- .../Parsing/ChartScatterParsersTests.cs | 123 ++++++++++++++++++ 2 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 tests/FlexRender.Tests/Parsing/ChartScatterParsersTests.cs diff --git a/src/FlexRender.Yaml/Parsing/ChartParsers.cs b/src/FlexRender.Yaml/Parsing/ChartParsers.cs index f5f9ab6..5ea29c0 100644 --- a/src/FlexRender.Yaml/Parsing/ChartParsers.cs +++ b/src/FlexRender.Yaml/Parsing/ChartParsers.cs @@ -26,7 +26,7 @@ internal static TemplateElement ParseChartElement(YamlMappingNode node, int maxS ArgumentNullException.ThrowIfNull(node); var chartType = ParseChartType(node); - var series = ParseSeries(node, maxSeries, maxDataPoints); + var series = ParseSeries(node, chartType, maxSeries, maxDataPoints); var chart = new ChartElement(chartType, series) { @@ -62,7 +62,7 @@ private static ChartType ParseChartType(YamlMappingNode node) if (!Enum.TryParse(raw, ignoreCase: true, out var chartType)) { throw new TemplateParseException( - $"Unknown chart-type '{raw}'. Valid values: bar, line, area, pie, donut."); + $"Unknown chart-type '{raw}'. Valid values: bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline."); } return chartType; } @@ -177,11 +177,12 @@ private static List ParseCategories(YamlMappingNode node) /// Parses the series sequence, enforcing the per-chart series limit. /// /// The chart mapping node. + /// The chart type, used to decide whether tuple (scatter/bubble) data is expected. /// The maximum number of series allowed. /// The maximum number of data points per series. /// The parsed series; empty when no series sequence is present. /// Thrown when the series limit is exceeded or an entry is malformed. - private static List ParseSeries(YamlMappingNode node, int maxSeries, int maxDataPoints) + private static List ParseSeries(YamlMappingNode node, ChartType chartType, int maxSeries, int maxDataPoints) { var result = new List(); @@ -199,23 +200,28 @@ private static List ParseSeries(YamlMappingNode node, int maxSeries if (item is not YamlMappingNode seriesNode) throw new TemplateParseException("Each entry in 'series' must be a mapping with a 'data' field."); - result.Add(ParseOneSeries(seriesNode, maxDataPoints)); + result.Add(ParseOneSeries(seriesNode, chartType, maxDataPoints)); } return result; } /// - /// Parses a single series entry, supporting an inline numeric array or a template expression. + /// Parses a single series entry, supporting an inline numeric array, an array-of-arrays of + /// XY/XY(R) tuples (scatter/bubble), or a template expression. /// /// The series mapping node. + /// The chart type, used to decide tuple arity rules for scatter/bubble. /// The maximum number of data points allowed in the series. /// The parsed . /// Thrown when the data-point limit is exceeded or a value is non-numeric/non-finite. - private static ChartSeries ParseOneSeries(YamlMappingNode seriesNode, int maxDataPoints) + private static ChartSeries ParseOneSeries(YamlMappingNode seriesNode, ChartType chartType, int maxDataPoints) { var label = GetStringValue(seriesNode, "label"); + // Bubble tuples may carry a third (radius) element; scatter tuples are strictly [x, y]. + var allowRadius = chartType is ChartType.Bubble; + // Inline array form. if (TryGetSequence(seriesNode, "data", out var dataSeq)) { @@ -225,6 +231,11 @@ private static ChartSeries ParseOneSeries(YamlMappingNode seriesNode, int maxDat $"Series '{label ?? "(unlabeled)"}' has {dataSeq.Children.Count} data points, which exceeds the maximum of {maxDataPoints}."); } + // Tuple data: the items are themselves sequences ([x, y] or [x, y, r]). + if (dataSeq.Children.Count > 0 && dataSeq.Children[0] is YamlSequenceNode) + return ParseTupleSeries(label, dataSeq, allowRadius); + + // Flat numeric data (bar/line/area/pie/donut/sparkline/gauge-progress series). var values = new List(dataSeq.Children.Count); foreach (var v in dataSeq.Children) { @@ -248,4 +259,58 @@ private static ChartSeries ParseOneSeries(YamlMappingNode seriesNode, int maxDat // No data at all -> empty inline series (renders as "no data" if all series empty). return ChartSeries.FromInline(label, Array.Empty()); } + + /// + /// Parses an array-of-arrays data sequence into XY (scatter) or XY(R) (bubble) tuple points. + /// + /// The series label, used in error messages. + /// The data sequence whose items are 2- or 3-element scalar sequences. + /// Whether a third (radius) element is permitted (bubble). + /// A point-bearing . + /// Thrown on wrong arity, non-numeric, or non-finite tuple values. + private static ChartSeries ParseTupleSeries(string? label, YamlSequenceNode dataSeq, bool allowRadius) + { + var points = new List(dataSeq.Children.Count); + foreach (var item in dataSeq.Children) + { + if (item is not YamlSequenceNode tuple) + { + throw new TemplateParseException( + $"Series '{label ?? "(unlabeled)"}' mixes tuple and scalar data; every item must be an [x, y] (or [x, y, r]) array."); + } + + var arity = tuple.Children.Count; + var maxArity = allowRadius ? 3 : 2; + if (arity < 2 || arity > maxArity) + { + throw new TemplateParseException( + $"Series '{label ?? "(unlabeled)"}' has a tuple with {arity} elements; expected 2{(allowRadius ? " or 3" : string.Empty)}."); + } + + var x = ParseTupleScalar(tuple.Children[0], label); + var y = ParseTupleScalar(tuple.Children[1], label); + var r = arity == 3 ? ParseTupleScalar(tuple.Children[2], label) : 0d; + points.Add(new ChartPoint(x, y, r)); + } + + return ChartSeries.FromPoints(label, points); + } + + /// Parses a single tuple element as a finite double, raising a named error otherwise. + /// The tuple element node. + /// The series label, used in error messages. + /// The parsed finite double. + /// Thrown when the value is non-numeric or non-finite. + private static double ParseTupleScalar(YamlNode node, string? label) + { + if (node is YamlScalarNode scalar + && double.TryParse(scalar.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) + && double.IsFinite(d)) + { + return d; + } + + throw new TemplateParseException( + $"Series '{label ?? "(unlabeled)"}' contains a non-numeric tuple value '{(node as YamlScalarNode)?.Value}'."); + } } diff --git a/tests/FlexRender.Tests/Parsing/ChartScatterParsersTests.cs b/tests/FlexRender.Tests/Parsing/ChartScatterParsersTests.cs new file mode 100644 index 0000000..1e548e0 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/ChartScatterParsersTests.cs @@ -0,0 +1,123 @@ +using FlexRender.Charts; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for parsing scatter/bubble tuple series and the updated chart-type validation. +/// +public sealed class ChartScatterParsersTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_ScatterTuples_ProducesPoints() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: scatter + width: 400 + height: 300 + series: + - label: cloud + data: [[1, 10], [2, 25], [3, 18]] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal(ChartType.Scatter, chart.ChartType); + var pts = chart.Series[0].Points; + Assert.Equal(3, pts.Count); + Assert.Equal(1d, pts[0].X); + Assert.Equal(10d, pts[0].Y); + Assert.Equal(0d, pts[0].R); + Assert.Empty(chart.Series[0].Data); + } + + [Fact] + public void Parse_BubbleTriples_ProducesPointsWithRadius() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: bubble + width: 400 + height: 300 + series: + - data: [[1, 10, 5], [2, 25, 12]] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + var pts = chart.Series[0].Points; + Assert.Equal(2, pts.Count); + Assert.Equal(5d, pts[0].R); + Assert.Equal(12d, pts[1].R); + } + + [Fact] + public void Parse_ScatterTupleWrongArity_Throws() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: scatter + width: 400 + height: 300 + series: + - label: bad + data: [[1, 2, 3]] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("bad", ex.Message); + } + + [Fact] + public void Parse_ScatterTupleNonNumeric_Throws() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: scatter + width: 400 + height: 300 + series: + - data: [[1, "x"]] + """; + + Assert.Throws(() => _parser.Parse(yaml)); + } + + [Fact] + public void Parse_UnknownChartType_ErrorListsNewTypes() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: nope + width: 400 + height: 300 + series: + - data: [1, 2] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("scatter", ex.Message); + } +} From 94686b6c028b57883574580f301d50c5aaf88909 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:03:38 +0400 Subject: [PATCH 066/119] feat(yaml): parse gauge/progress value/max/label and register chart props Gauge and progress charts read chart-level value (double?), max (double?) and label (the value caption -> ChartElement.ValueLabel). The new value, max and label keys are registered in KnownProperties.Chart so they pass unknown-property validation and surface typo suggestions. Chart-level label is distinct from the per-series label, which is validated inside the series mapping scope. --- src/FlexRender.Yaml/Parsing/ChartParsers.cs | 3 + .../Parsing/KnownProperties.cs | 1 + .../Parsing/ChartGaugeParsersTests.cs | 100 ++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 tests/FlexRender.Tests/Parsing/ChartGaugeParsersTests.cs diff --git a/src/FlexRender.Yaml/Parsing/ChartParsers.cs b/src/FlexRender.Yaml/Parsing/ChartParsers.cs index 5ea29c0..0c4c711 100644 --- a/src/FlexRender.Yaml/Parsing/ChartParsers.cs +++ b/src/FlexRender.Yaml/Parsing/ChartParsers.cs @@ -40,6 +40,9 @@ internal static TemplateElement ParseChartElement(YamlMappingNode node, int maxS Smooth = GetBoolValue(node, "smooth", false), ShowPoints = GetBoolValue(node, "points", false), PieLabels = ParsePieLabels(node), + Value = GetDoubleValue(node, "value"), + Max = GetDoubleValue(node, "max"), + ValueLabel = GetStringValue(node, "label"), Background = GetStringValue(node, "background")!, Rotate = GetExprStringValue(node, "rotate", "none"), Padding = GetExprStringValue(node, "padding", "0"), diff --git a/src/FlexRender.Yaml/Parsing/KnownProperties.cs b/src/FlexRender.Yaml/Parsing/KnownProperties.cs index d067de2..70560eb 100644 --- a/src/FlexRender.Yaml/Parsing/KnownProperties.cs +++ b/src/FlexRender.Yaml/Parsing/KnownProperties.cs @@ -200,6 +200,7 @@ internal static class KnownProperties [ "chart-type", "categories", "series", "palette", "theme", "legend", "title", "horizontal", "stacked", "smooth", "points", "labels", + "value", "max", "label", "background", "rotate", "padding", "margin" ]); diff --git a/tests/FlexRender.Tests/Parsing/ChartGaugeParsersTests.cs b/tests/FlexRender.Tests/Parsing/ChartGaugeParsersTests.cs new file mode 100644 index 0000000..0371bf4 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/ChartGaugeParsersTests.cs @@ -0,0 +1,100 @@ +using FlexRender.Charts; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for parsing gauge/progress value/max/label and property validation. +/// +public sealed class ChartGaugeParsersTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_Gauge_ReadsValueMaxLabel() + { + const string yaml = """ + canvas: + width: 300 + layout: + - type: chart + chart-type: gauge + width: 300 + height: 200 + value: 72 + max: 100 + label: CPU + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal(ChartType.Gauge, chart.ChartType); + Assert.Equal(72d, chart.Value); + Assert.Equal(100d, chart.Value is null ? 0d : chart.Max); + Assert.Equal("CPU", chart.ValueLabel); + } + + [Fact] + public void Parse_Progress_DefaultsMaxLabelToNull() + { + const string yaml = """ + canvas: + width: 300 + layout: + - type: chart + chart-type: progress + width: 300 + height: 80 + value: 40 + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal(40d, chart.Value); + Assert.Null(chart.Max); + Assert.Null(chart.ValueLabel); + } + + [Fact] + public void Parse_GaugeWithKnownProps_DoesNotThrow() + { + const string yaml = """ + canvas: + width: 300 + layout: + - type: chart + chart-type: gauge + width: 300 + height: 200 + value: 50 + max: 80 + label: Disk + theme: dark + """; + + var template = _parser.Parse(yaml); + Assert.IsType(template.Elements[0]); + } + + [Fact] + public void Parse_TypoInValue_Throws() + { + const string yaml = """ + canvas: + width: 300 + layout: + - type: chart + chart-type: gauge + width: 300 + height: 200 + valeu: 50 + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("valeu", ex.Message); + } +} From d0994da901d398a0d74a6546e8178f895b50c51e Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:06:05 +0400 Subject: [PATCH 067/119] feat(skia): add chart tuple-point bounds and gauge data detection --- .../Rendering/ChartRenderer.cs | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index f188e86..0789f14 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -68,12 +68,15 @@ internal static void Draw( } } - /// Returns whether any series has at least one data point. + /// Returns whether the chart has anything to draw (flat data, tuple points, or a gauge value). private static bool HasAnyData(ChartElement chart) { + if (chart.ChartType is ChartType.Gauge or ChartType.Progress) + return chart.Value is not null; + foreach (var s in chart.Series) { - if (s.Data.Count > 0) + if (s.Data.Count > 0 || s.Points.Count > 0) return true; } return false; @@ -190,6 +193,33 @@ private static (double Min, double Max) DataBounds(ChartElement chart) return (min, max); } + /// + /// Computes the X and Y data ranges across all series' tuple points (scatter/bubble). + /// Returns unit ranges when there are no points so a chart can still draw. + /// + private static (double MinX, double MaxX, double MinY, double MaxY) PointBounds(ChartElement chart) + { + var minX = double.MaxValue; + var maxX = double.MinValue; + var minY = double.MaxValue; + var maxY = double.MinValue; + + foreach (var s in chart.Series) + { + foreach (var p in s.Points) + { + if (p.X < minX) minX = p.X; + if (p.X > maxX) maxX = p.X; + if (p.Y < minY) minY = p.Y; + if (p.Y > maxY) maxY = p.Y; + } + } + + if (minX == double.MaxValue) + return (0d, 1d, 0d, 1d); + return (minX, maxX, minY, maxY); + } + /// /// Computes the value-axis bounds for stacked bars: the maximum is the largest per-category /// stacked total (sum of all series' values at that category) and the minimum is the smallest From 3c415bf286111568bf2ac2329aa905b737212508 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:07:08 +0400 Subject: [PATCH 068/119] feat(skia): render sparkline charts --- .../Rendering/ChartRenderer.cs | 59 +++++++++++++++++++ .../Rendering/ChartSparklineRenderTests.cs | 48 +++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 tests/FlexRender.Tests/Rendering/ChartSparklineRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 0789f14..5a58dae 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -159,6 +159,9 @@ private static void DrawSeries( case ChartType.Donut: DrawPie(canvas, chart, theme, width, height, typeface, isDonut: true, antialias); break; + case ChartType.Sparkline: + DrawSparkline(canvas, chart, theme, width, height, antialias); + break; default: // Other chart types are added in later tasks. using (var border = new SKPaint @@ -597,6 +600,62 @@ private static void DrawLineOrArea( DrawCategoryLabels(canvas, chart, theme, plot, typeface, antialias); } + /// + /// Draws a sparkline: a single small line filling the box (minus a small inset) with no grid, + /// axes, labels, or legend. Honors and + /// ; uses the first series' flat data. + /// + private static void DrawSparkline( + SKCanvas canvas, ChartElement chart, ChartTheme theme, float width, float height, bool antialias) + { + if (chart.Series.Count == 0) + return; + + var data = chart.Series[0].Data; + if (data.Count == 0) + return; + + var inset = MathF.Max(2f, MathF.Min(width, height) * 0.12f); + var left = inset; + var top = inset; + var right = width - inset; + var bottom = height - inset; + if (right <= left || bottom <= top) + return; + + var (dataMin, dataMax) = DataBounds(chart); + var mapper = new ValueMapper(dataMin, dataMax, top, bottom); + + var step = data.Count > 1 ? (right - left) / (data.Count - 1) : 0f; + float X(int i) => data.Count > 1 ? left + (step * i) : (left + right) / 2f; + + var points = new SKPoint[data.Count]; + for (var i = 0; i < data.Count; i++) + points[i] = new SKPoint(X(i), mapper.MapY(data[i])); + + var palette = chart.Palette ?? ChartPalettes.Default; + var color = ColorParser.Parse(palette.ColorAt(0)); + + using var path = BuildSeriesPath(points, chart.Smooth); + using var linePaint = new SKPaint + { + Color = color, + Style = SKPaintStyle.Stroke, + StrokeWidth = MathF.Max(1f, theme.LineWidth - 0.5f), + IsAntialias = antialias, + StrokeCap = SKStrokeCap.Round, + StrokeJoin = SKStrokeJoin.Round + }; + canvas.DrawPath(path, linePaint); + + if (chart.ShowPoints) + { + using var pointPaint = new SKPaint { Color = color, Style = SKPaintStyle.Fill, IsAntialias = antialias }; + for (var i = 0; i < data.Count; i++) + canvas.DrawCircle(X(i), mapper.MapY(data[i]), theme.LineWidth, pointPaint); + } + } + /// /// Builds an open path through the given series points. When is true, /// adjacent points are joined with cubic Bézier segments derived from Catmull-Rom tangents, diff --git a/tests/FlexRender.Tests/Rendering/ChartSparklineRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartSparklineRenderTests.cs new file mode 100644 index 0000000..cd51d3d --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartSparklineRenderTests.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies the sparkline draws a colored line with no axis chrome. +/// +public sealed class ChartSparklineRenderTests +{ + [Fact] + public void Sparkline_DrawsColoredLine() + { + var chart = new ChartElement(ChartType.Sparkline, new List + { + ChartSeries.FromInline(null, new[] { 3d, 8d, 4d, 10d, 6d }) + }) + { + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(120, 40, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 120f, 40f, typeface: null, antialias: true); + + Assert.True(HasRedPixel(bitmap), "Expected a red sparkline."); + } + + private static bool HasRedPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 180 && p.Green < 100 && p.Blue < 100) + return true; + } + return false; + } +} From 597871d0a311d41410bd9a5f9011dfb6e803ab91 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:08:20 +0400 Subject: [PATCH 069/119] feat(skia): render scatter and bubble charts --- .../Rendering/ChartRenderer.cs | 78 ++++++++++++++++ .../Rendering/ChartScatterRenderTests.cs | 89 +++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 tests/FlexRender.Tests/Rendering/ChartScatterRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 5a58dae..167686c 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -162,6 +162,12 @@ private static void DrawSeries( case ChartType.Sparkline: DrawSparkline(canvas, chart, theme, width, height, antialias); break; + case ChartType.Scatter: + DrawScatterOrBubble(canvas, chart, theme, width, height, typeface, isBubble: false, antialias); + break; + case ChartType.Bubble: + DrawScatterOrBubble(canvas, chart, theme, width, height, typeface, isBubble: true, antialias); + break; default: // Other chart types are added in later tasks. using (var border = new SKPaint @@ -656,6 +662,78 @@ private static void DrawSparkline( } } + /// + /// Draws a scatter (dots) or bubble (radius-scaled dots) chart. Both axes are scaled to the + /// point data ranges; bubble marker radii are mapped from the largest + /// to a fixed maximum pixel radius so the largest bubble fits comfortably in the plot. + /// + private static void DrawScatterOrBubble( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool isBubble, bool antialias) + { + var (minX, maxX, minY, maxY) = PointBounds(chart); + var xScale = AxisScale.Compute(minX, maxX, targetTicks: 5); + var yScale = AxisScale.Compute(minY, maxY, targetTicks: 5); + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var plot = ChartLayout.ComputePlotArea( + width, height, hasTitle, chart.Legend, + axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f); + + DrawGridAndYAxis(canvas, theme, plot, yScale, typeface, antialias); + + var yMapper = new ValueMapper(yScale.Min, yScale.Max, plot.Top, plot.Bottom); + + // X mapping mirrors ValueMapper.MapY but along the horizontal axis (min -> left, max -> right). + var plotLeft = plot.Left; + var plotWidth = plot.Width; + var xMin = xScale.Min; + var xSpan = xScale.Max - xScale.Min; + float MapX(double value) => xSpan <= 0d + ? plotLeft + (plotWidth / 2f) + : plotLeft + (float)(((value - xMin) / xSpan) * plotWidth); + + // Determine the largest radius for bubble scaling. + var maxR = 0d; + if (isBubble) + { + foreach (var s in chart.Series) + foreach (var p in s.Points) + if (p.R > maxR) maxR = p.R; + } + var maxPixelRadius = MathF.Max(4f, MathF.Min(plot.Width, plot.Height) * 0.12f); + const float scatterRadius = 4f; + + var palette = chart.Palette ?? ChartPalettes.Default; + + for (var si = 0; si < chart.Series.Count; si++) + { + var pts = chart.Series[si].Points; + if (pts.Count == 0) + continue; + + var color = ColorParser.Parse(palette.ColorAt(si)); + using var paint = new SKPaint + { + Color = isBubble ? color.WithAlpha(150) : color, + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + + foreach (var p in pts) + { + var cx = MapX(p.X); + var cy = yMapper.MapY(p.Y); + float radius; + if (isBubble && maxR > 0d) + radius = MathF.Max(2f, (float)(p.R / maxR) * maxPixelRadius); + else + radius = scatterRadius; + canvas.DrawCircle(cx, cy, radius, paint); + } + } + } + /// /// Builds an open path through the given series points. When is true, /// adjacent points are joined with cubic Bézier segments derived from Catmull-Rom tangents, diff --git a/tests/FlexRender.Tests/Rendering/ChartScatterRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartScatterRenderTests.cs new file mode 100644 index 0000000..39b92f6 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartScatterRenderTests.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies scatter and bubble draw palette-colored markers. +/// +public sealed class ChartScatterRenderTests +{ + [Fact] + public void Scatter_DrawsColoredDots() + { + var chart = new ChartElement(ChartType.Scatter, new List + { + ChartSeries.FromPoints("cloud", new List + { + ChartPoint.Xy(1d, 10d), ChartPoint.Xy(5d, 25d), ChartPoint.Xy(9d, 18d) + }) + }) + { + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true); + + Assert.True(HasRedPixel(bitmap), "Expected red scatter dots."); + } + + [Fact] + public void Bubble_DrawsLargerMarkersForLargerRadius() + { + var chart = new ChartElement(ChartType.Bubble, new List + { + ChartSeries.FromPoints("b", new List + { + new ChartPoint(2d, 10d, 2d), new ChartPoint(8d, 20d, 30d) + }) + }) + { + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#0000ff" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(300, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 300f, 200f, typeface: null, antialias: true); + + Assert.True(CountBluePixels(bitmap) > 50, "Expected substantial blue bubble area."); + } + + private static bool HasRedPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 180 && p.Green < 100 && p.Blue < 100) + return true; + } + return false; + } + + private static int CountBluePixels(SKBitmap bitmap) + { + var count = 0; + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Blue > 150 && p.Red < 120 && p.Green < 120) + count++; + } + return count; + } +} From 7917ee5edc397e1d04b02d78e7ee4223b5bee188 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:11:27 +0400 Subject: [PATCH 070/119] feat(skia): render gauge charts --- .../Rendering/ChartRenderer.cs | 85 +++++++++++++++++++ .../Rendering/ChartGaugeRenderTests.cs | 77 +++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/FlexRender.Tests/Rendering/ChartGaugeRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 167686c..7370990 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -168,6 +168,9 @@ private static void DrawSeries( case ChartType.Bubble: DrawScatterOrBubble(canvas, chart, theme, width, height, typeface, isBubble: true, antialias); break; + case ChartType.Gauge: + DrawGauge(canvas, chart, theme, width, height, typeface, antialias); + break; default: // Other chart types are added in later tasks. using (var border = new SKPaint @@ -734,6 +737,88 @@ float MapX(double value) => xSpan <= 0d } } + /// + /// Draws a 270-degree gauge dial: a faint background arc plus a colored value arc proportional + /// to value/max, with the value and optional caption centered below the dial. + /// + private static void DrawGauge( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + var value = chart.Value ?? 0d; + var max = chart.Max is > 0d ? chart.Max.Value : 100d; + var fraction = max <= 0d ? 0d : Math.Clamp(value / max, 0d, 1d); + + // Geometry: a 270-degree sweep starting at 135 degrees (bottom-left), open at the bottom. + const float startAngle = 135f; + const float fullSweep = 270f; + + var diameter = MathF.Min(width, height) * 0.7f; + var radius = diameter / 2f; + var cx = width / 2f; + var cy = height / 2f; + var thickness = MathF.Max(6f, radius * 0.22f); + + var bounds = new SKRect(cx - radius, cy - radius, cx + radius, cy + radius); + var palette = chart.Palette ?? ChartPalettes.Default; + var valueColor = ColorParser.Parse(palette.ColorAt(0)); + + using var trackPaint = new SKPaint + { + Color = ColorParser.Parse(theme.GridColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = thickness, + StrokeCap = SKStrokeCap.Round, + IsAntialias = antialias + }; + using var track = new SKPath(); + track.AddArc(bounds, startAngle, fullSweep); + canvas.DrawPath(track, trackPaint); + + if (fraction > 0d) + { + using var valuePaint = new SKPaint + { + Color = valueColor, + Style = SKPaintStyle.Stroke, + StrokeWidth = thickness, + StrokeCap = SKStrokeCap.Round, + IsAntialias = antialias + }; + using var valueArc = new SKPath(); + valueArc.AddArc(bounds, startAngle, (float)(fullSweep * fraction)); + canvas.DrawPath(valueArc, valuePaint); + } + + DrawIndicatorText(canvas, chart, theme, value, max, cx, cy, typeface, antialias); + } + + /// + /// Draws the centered numeric value (and optional caption) for gauge/progress charts when a + /// typeface is available. + /// + private static void DrawIndicatorText( + SKCanvas canvas, ChartElement chart, ChartTheme theme, double value, double max, + float cx, float cy, SKTypeface? typeface, bool antialias) + { + if (typeface is null) + return; + + var valueText = FormatTick(value); + using var valueFont = new SKFont(typeface, theme.TitleSize); + using var valuePaint = new SKPaint { Color = ColorParser.Parse(theme.TitleColor), IsAntialias = antialias }; + var vw = valueFont.MeasureText(valueText); + canvas.DrawText(valueText, cx - (vw / 2f), cy + (theme.TitleSize / 3f), SKTextAlign.Left, valueFont, valuePaint); + + if (string.IsNullOrEmpty(chart.ValueLabel)) + return; + + using var capFont = new SKFont(typeface, theme.LabelSize); + using var capPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + var cw = capFont.MeasureText(chart.ValueLabel); + canvas.DrawText(chart.ValueLabel, cx - (cw / 2f), cy + theme.TitleSize, SKTextAlign.Left, capFont, capPaint); + } + /// /// Builds an open path through the given series points. When is true, /// adjacent points are joined with cubic Bézier segments derived from Catmull-Rom tangents, diff --git a/tests/FlexRender.Tests/Rendering/ChartGaugeRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartGaugeRenderTests.cs new file mode 100644 index 0000000..abb3d48 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartGaugeRenderTests.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies the gauge draws a colored value arc. +/// +public sealed class ChartGaugeRenderTests +{ + [Fact] + public void Gauge_DrawsColoredValueArc() + { + var chart = new ChartElement(ChartType.Gauge, new List()) + { + Value = 70d, + Max = 100d, + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(240, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 240f, 200f, typeface: null, antialias: true); + + Assert.True(HasRedPixel(bitmap), "Expected a red gauge arc."); + } + + [Fact] + public void Gauge_MissingValue_DrawsNoDataPlaceholder() + { + var chart = new ChartElement(ChartType.Gauge, new List()) + { + Legend = LegendPosition.None, + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(240, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 240f, 200f, typeface: null, antialias: true); + + Assert.True(HasNonWhitePixel(bitmap), "Expected the no-data border to draw."); + } + + private static bool HasRedPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 180 && p.Green < 100 && p.Blue < 100) + return true; + } + return false; + } + + private static bool HasNonWhitePixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red < 240 || p.Green < 240 || p.Blue < 240) + return true; + } + return false; + } +} From 33f75e65dc58aca55bbb6518ed711ebbcff25af2 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:12:21 +0400 Subject: [PATCH 071/119] feat(skia): render progress ring charts --- .../Rendering/ChartRenderer.cs | 53 +++++++++++++++++++ .../Rendering/ChartProgressRenderTests.cs | 47 ++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 tests/FlexRender.Tests/Rendering/ChartProgressRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 7370990..78e534f 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -171,6 +171,9 @@ private static void DrawSeries( case ChartType.Gauge: DrawGauge(canvas, chart, theme, width, height, typeface, antialias); break; + case ChartType.Progress: + DrawProgress(canvas, chart, theme, width, height, typeface, antialias); + break; default: // Other chart types are added in later tasks. using (var border = new SKPaint @@ -819,6 +822,56 @@ private static void DrawIndicatorText( canvas.DrawText(chart.ValueLabel, cx - (cw / 2f), cy + theme.TitleSize, SKTextAlign.Left, capFont, capPaint); } + /// + /// Draws a progress ring: a full-circle faint track plus a colored value arc starting at 12 + /// o'clock and sweeping clockwise proportional to value/max, with centered value text. + /// + private static void DrawProgress( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + var value = chart.Value ?? 0d; + var max = chart.Max is > 0d ? chart.Max.Value : 100d; + var fraction = max <= 0d ? 0d : Math.Clamp(value / max, 0d, 1d); + + var diameter = MathF.Min(width, height) * 0.7f; + var radius = diameter / 2f; + var cx = width / 2f; + var cy = height / 2f; + var thickness = MathF.Max(6f, radius * 0.2f); + + var bounds = new SKRect(cx - radius, cy - radius, cx + radius, cy + radius); + var palette = chart.Palette ?? ChartPalettes.Default; + var valueColor = ColorParser.Parse(palette.ColorAt(0)); + + using var trackPaint = new SKPaint + { + Color = ColorParser.Parse(theme.GridColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = thickness, + IsAntialias = antialias + }; + canvas.DrawOval(bounds, trackPaint); + + if (fraction > 0d) + { + using var valuePaint = new SKPaint + { + Color = valueColor, + Style = SKPaintStyle.Stroke, + StrokeWidth = thickness, + StrokeCap = SKStrokeCap.Round, + IsAntialias = antialias + }; + using var valueArc = new SKPath(); + // Start at 12 o'clock (-90 degrees) and sweep clockwise. + valueArc.AddArc(bounds, -90f, (float)(360d * fraction)); + canvas.DrawPath(valueArc, valuePaint); + } + + DrawIndicatorText(canvas, chart, theme, value, max, cx, cy, typeface, antialias); + } + /// /// Builds an open path through the given series points. When is true, /// adjacent points are joined with cubic Bézier segments derived from Catmull-Rom tangents, diff --git a/tests/FlexRender.Tests/Rendering/ChartProgressRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartProgressRenderTests.cs new file mode 100644 index 0000000..54e9f7c --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartProgressRenderTests.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies the progress ring draws a colored value arc. +/// +public sealed class ChartProgressRenderTests +{ + [Fact] + public void Progress_DrawsColoredRingArc() + { + var chart = new ChartElement(ChartType.Progress, new List()) + { + Value = 40d, + Max = 100d, + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: true); + + Assert.True(HasRedPixel(bitmap), "Expected a red progress arc."); + } + + private static bool HasRedPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 180 && p.Green < 100 && p.Blue < 100) + return true; + } + return false; + } +} From 8475bea806c90525d7e9eba0b4f017316ae9271e Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:14:19 +0400 Subject: [PATCH 072/119] test(skia): end-to-end smoke tests for Phase-3 chart types --- .../Rendering/ChartRenderSmokeTests.cs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs b/tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs index 46ff0e6..473bb45 100644 --- a/tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs +++ b/tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs @@ -69,6 +69,123 @@ public async Task BarChart_WithData_DrawsColoredPixels() Assert.True(HasNonBackgroundPixel(bitmap), "Expected the bar chart to draw something."); } + [Fact] + public async Task ScatterChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 320 + height: 240 + background: "#ffffff" + layout: + - type: chart + chart-type: scatter + width: 320 + height: 240 + series: + - data: [[1, 10], [4, 25], [7, 18], [9, 30]] + legend: none + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected scatter to draw."); + } + + [Fact] + public async Task BubbleChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 320 + height: 240 + background: "#ffffff" + layout: + - type: chart + chart-type: bubble + width: 320 + height: 240 + series: + - data: [[1, 10, 5], [5, 25, 18], [8, 15, 9]] + legend: none + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected bubble to draw."); + } + + [Fact] + public async Task GaugeChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 240 + height: 200 + background: "#ffffff" + layout: + - type: chart + chart-type: gauge + width: 240 + height: 200 + value: 65 + max: 100 + label: CPU + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected gauge to draw."); + } + + [Fact] + public async Task ProgressChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 200 + height: 200 + background: "#ffffff" + layout: + - type: chart + chart-type: progress + width: 200 + height: 200 + value: 45 + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected progress to draw."); + } + + [Fact] + public async Task SparklineChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 160 + height: 50 + background: "#ffffff" + layout: + - type: chart + chart-type: sparkline + width: 160 + height: 50 + series: + - data: [3, 8, 4, 10, 6, 9] + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected sparkline to draw."); + } + private static bool HasNonBackgroundPixel(SKBitmap bitmap) { for (var y = 0; y < bitmap.Height; y += 3) From 0b83e67638f577d4b12322227f1202423f722998 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:15:27 +0400 Subject: [PATCH 073/119] test(charts): snapshot goldens for scatter/bubble/gauge/progress/sparkline --- .../Snapshots/ChartSnapshotTests.cs | 101 ++++++++++++++++++ .../Snapshots/golden/chart_bubble_light.png | Bin 0 -> 7056 bytes .../Snapshots/golden/chart_gauge_dark.png | Bin 0 -> 6615 bytes .../Snapshots/golden/chart_progress_light.png | Bin 0 -> 8682 bytes .../Snapshots/golden/chart_scatter_light.png | Bin 0 -> 4695 bytes .../golden/chart_sparkline_light.png | Bin 0 -> 4246 bytes 6 files changed, 101 insertions(+) create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_bubble_light.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_gauge_dark.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_progress_light.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_scatter_light.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_sparkline_light.png diff --git a/tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs b/tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs index 06da14d..9ce6048 100644 --- a/tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs +++ b/tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs @@ -143,6 +143,107 @@ public async Task DonutChart_Dark() await AssertSnapshot("chart_donut_dark", template, new ObjectValue()); } + [Fact] + public async Task ScatterChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 480 + background: "#ffffff" + layout: + - type: chart + chart-type: scatter + width: 480 + height: 320 + series: + - label: cloud + data: [[1, 12], [3, 30], [5, 22], [7, 48], [9, 35]] + legend: none + palette: ocean + """); + await AssertSnapshot("chart_scatter_light", template, new ObjectValue()); + } + + [Fact] + public async Task BubbleChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 480 + background: "#ffffff" + layout: + - type: chart + chart-type: bubble + width: 480 + height: 320 + series: + - data: [[1, 12, 6], [3, 30, 18], [5, 22, 10], [7, 48, 24]] + legend: none + palette: sunset + """); + await AssertSnapshot("chart_bubble_light", template, new ObjectValue()); + } + + [Fact] + public async Task GaugeChart_Dark() + { + var template = Parser.Parse(""" + canvas: + width: 280 + background: "#1e1e1e" + layout: + - type: chart + chart-type: gauge + width: 280 + height: 220 + value: 72 + max: 100 + label: CPU + theme: dark + palette: vivid + """); + await AssertSnapshot("chart_gauge_dark", template, new ObjectValue()); + } + + [Fact] + public async Task ProgressChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 240 + background: "#ffffff" + layout: + - type: chart + chart-type: progress + width: 240 + height: 240 + value: 64 + max: 100 + label: Disk + palette: forest + """); + await AssertSnapshot("chart_progress_light", template, new ObjectValue()); + } + + [Fact] + public async Task SparklineChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 200 + background: "#ffffff" + layout: + - type: chart + chart-type: sparkline + width: 200 + height: 50 + smooth: true + series: + - data: [3, 8, 4, 10, 6, 9, 5, 11] + """); + await AssertSnapshot("chart_sparkline_light", template, new ObjectValue()); + } + [Fact] public async Task EmptyChart_NoDataPlaceholder() { diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_bubble_light.png b/tests/FlexRender.Tests/Snapshots/golden/chart_bubble_light.png new file mode 100644 index 0000000000000000000000000000000000000000..e51df9ecd14eb39eae87aff884f5e5d4bd60804b GIT binary patch literal 7056 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 G+y4Uj@4=1$ literal 0 HcmV?d00001 diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_gauge_dark.png b/tests/FlexRender.Tests/Snapshots/golden/chart_gauge_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..40df9c913de46bfc1ade761e2de7faeab4fc1bb6 GIT binary patch literal 6615 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 McR9xV#{cp^0GCm7b^rhX literal 0 HcmV?d00001 diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_sparkline_light.png b/tests/FlexRender.Tests/Snapshots/golden/chart_sparkline_light.png new file mode 100644 index 0000000000000000000000000000000000000000..33293800c55f554c8426e9f54eda71bbf4135bd1 GIT binary patch literal 4246 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 literal 0 HcmV?d00001 From 8d53bca9dd64dfdd7925e90cd150320d4a2d7158 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:18:05 +0400 Subject: [PATCH 074/119] fix(renderer): suppress title and legend chrome for sparkline charts --- .../Rendering/ChartRenderer.cs | 7 +++++-- .../golden/chart_sparkline_light.png | Bin 4246 -> 4246 bytes 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 78e534f..417cacb 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -59,8 +59,11 @@ internal static void Draw( } DrawSeries(canvas, chart, theme, width, height, typeface, antialias); - DrawTitle(canvas, chart, theme, width, typeface, antialias); - DrawLegend(canvas, chart, theme, width, height, typeface, antialias); + if (chart.ChartType != ChartType.Sparkline) + { + DrawTitle(canvas, chart, theme, width, typeface, antialias); + DrawLegend(canvas, chart, theme, width, height, typeface, antialias); + } } finally { diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_sparkline_light.png b/tests/FlexRender.Tests/Snapshots/golden/chart_sparkline_light.png index 33293800c55f554c8426e9f54eda71bbf4135bd1..0396a71e60ff74a2a92ef45433520116f5143cc6 100644 GIT binary patch delta 3382 zcmV-64axGBA(kPKGk*;FNkl;VV}jAR6X2nh6p zAP@n8eh>sAAkYtjKmuxQ6!MZJOw5$Bcv2k6 z(ZWX_Ol%GpTPqp?xFI!y)iV)qwe`RIai*2JbAbYfE`P-nAlHhbqzrv!{p#6TK@P! zD|CnKl0}(BM|k!=PF*qZ((x8r^<4lI&r6{&KQ^G_9j&N3?A*7z4r`Z-duJ!HWK!Iq z?h^zu41eDIw1XFqxg*ESyeJ;IJ$=A?%yt*sDjNBuJDRMXk-**468t{a;b68ox!hu< zw#|am36teA~c7w(H7j^7B>NZcFSs`ak zvVX*@`vrk`iN)^XzTI^+b=nz|Ea90I@?kyiSdE_F9C1gYgGCUCms|_iQ4$f)ugW3G2Y+>qcdB*#w%Z~OmPq!VZRal^wE-ZLi1^nz z$;=zyfBIywI{DuZS~+^56S|9T1JxA*h0*4ucy~Q&ot<09Mze8Y+K`?TG=a1m9enhq zp7Tl*T@DxH(<7OX9?6Y^u-<}2)l~yq-cSLs^(UF!oF(kF@4Cv*W+igh)PH!t{GQ(b zi`3XK9=juB*w$^c&d#F;8&GxF$xD*3ttiVY$H*6)ylkNKEq9dPvN)5;^5I4KfC!|n z&B~KUnyA;;7w6$Fqmylc7GmKQ+3qf>ME+}T3Xx$0e^AnJ z5exETQMTJqwA(m!#Xw3-7}qCx=VAMT(uyV&?KWoSMe(x$)+uhxl;Uu@xS%p~L1m^z zZ)Zl{&~4Ncm-K8dZ+}E*b`lvb;_*8&{I<@E3>PsYFPalII+{A|oUJu5du)u~+Wswe z7Z1Otq($Gwq--f0ZyUIyVX*imf*7n$HkUV2tuiB%hM%L@!-*D- z)#w2jlPuwp+cN^vcHh`xXTx3von{B}gmAVm&GO0}8w@swi^mQ%@L7YI?D%kY-7WX2 zgXVLAH0$gD}D6c~J&a zbAs1-d$!IF*ht^Z z7|&L!IeNiu4c|XMg?SVF7fBpW7wgItTy3?oAU~F0&VLK+m&y~hZB`yX)QHB|MF_BX zQXD^>l}LE#*Jn$o3y#<5clY~RB2+<0r%EA@`P|o7G(}A%`1IN#mUPCq9h`o zyer#p-Nil;NWI3&#{Eik7AH4nNh!HAoiMTQzkgItmy0J4H}Sz`1ELVvyeNaoKAsh8 z)MnPds>B66wJeJp(nelozea6l*E`K9+if1thlhr+AU~G73gSmp6XBU!15cEzZ~-w9 zBA!^B$@fwQP8EA&u{$X#Z{)KEGtm(uwijiQljuM5X4nF0(AwCrSBcKzU|O!M&+Suu z$$yqNR8(Cx5EdH3mL-`!YfsUd9Q^Wc^=J%TES(z9&qqjiZC})=Eu5(}Qmrx*86HBM zR7^^27=`(fKD_iFIWPE;n1aK1srJD~H#mE6_LR z6ehM-s4&~y=D~veSk}%?`c}tqZ)(&Qw!G1Z*5n{9HjM2{vjP$whb$1S$-(+p6*TMY zj7yE6v?wc}+uImzPS%$xP-<<+BqDY!%_4Ugq4w#w)x7(Kj>xbOUbrWR6hBwXM}Irb zIy*ZKH<@Z)y=QbHArw@C~={YMYfkRT@rRaaaCl{yx=l!dI;*KJVnuCtG{0 z=meRVF)0#q6Cg|l;6woiR+Z|N{O zc&J+>01qupW40H)Tj!M~9_e;h7Eg*}?d*~F>g#tMy~WAPAGUJj^G=WFZ@V#;HM5d} zQmFM>AWt1t^WH@r2~lF6Ss@>=uPP8|O%8rlR!>X!ZXtv1g8jJO#I}k?EPr-azzha~ zzA@XJ)V5hswA;`ax`>mBnKLdrXl?U&E0En)8upyl5D^x_)63-}YG;c^t({E=lsz?o zn=+-Wos-1%eXL84Ug+f6%I2PHON#Xm zW@Y_e1s1!DH8T@gR^XpE-2A~Sr!AQC1q8l}c+536mp4+cu^~?gXY=Ar!Ql}YwRjYWwaZCvqJ+|-te_TY z1O$SOu=S{bKtBlKK`KC?9|VC22=s#>5CMUH5CkG1&=3C)STtS?U$h3500000NkvXX Mu0mjfvqKG21BMNS%K!iX delta 3382 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 MJe(I_vqKG21E46n2><{9 From 37198cbdf52cde7cf88642b0c7801408d490153d Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:19:46 +0400 Subject: [PATCH 075/119] docs(playground): add Phase-3 chart types and gauge props to schema Add scatter/bubble/gauge/progress/sparkline to chart-type enum, allow tuple-form series data ([x,y]/[x,y,r] arrays), and add value/max/label properties to chartElement. yaml-autocomplete.mjs derives completions dynamically from the schema (no hardcoded chart-type list), so no change required there. --- .../wwwroot/schemas/flexrender-template.json | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json b/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json index 2315aae..55c9beb 100644 --- a/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json +++ b/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json @@ -880,7 +880,7 @@ "chart-type": { "description": "Chart kind.", "type": "string", - "enum": ["bar", "line", "area", "pie", "donut"] + "enum": ["bar", "line", "area", "pie", "donut", "scatter", "bubble", "gauge", "progress", "sparkline"] }, "categories": { "description": "X-axis category labels (bar/line/area) or slice labels (pie/donut).", @@ -898,9 +898,10 @@ "type": "string" }, "data": { - "description": "Inline array of numbers OR a {{ expr }} string resolving to a number array.", + "description": "Flat number array (bar/line/area/pie/donut/sparkline), OR an array of [x, y] / [x, y, r] tuples (scatter/bubble), OR a {{ expr }} string resolving to a number array.", "oneOf": [ { "type": "array", "items": { "type": "number" } }, + { "type": "array", "items": { "type": "array", "items": { "type": "number" } } }, { "type": "string" } ] } @@ -948,6 +949,18 @@ "description": "Pie/donut only. Slice label content.", "type": "string", "enum": ["percent", "value", "none"] + }, + "value": { + "description": "Gauge/progress only. The indicator value.", + "type": "number" + }, + "max": { + "description": "Gauge/progress only. The indicator maximum (default 100).", + "type": "number" + }, + "label": { + "description": "Gauge/progress only. Centered caption under the value.", + "type": "string" } } }, From 18cd49e0b8b9e29df64ba044c1006ea4cbb32e65 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:21:24 +0400 Subject: [PATCH 076/119] docs(wiki): document Phase-3 chart types with visual examples Element-Reference: extend chart-type valid values with scatter/bubble/gauge/progress/sparkline; add value/max/label rows (gauge/progress only); note scatter/bubble tuple data format and chrome-free sparkline. Visual-Reference: add five new chart examples with golden image references and matching examples/visual-docs/chart/*.yaml sources. --- docs/wiki/Element-Reference.md | 9 +- docs/wiki/Visual-Reference.md | 108 +++++++++++++++++- examples/visual-docs/chart/bubble-light.yaml | 20 ++++ examples/visual-docs/chart/gauge-dark.yaml | 21 ++++ .../visual-docs/chart/progress-light.yaml | 20 ++++ examples/visual-docs/chart/scatter-light.yaml | 21 ++++ .../visual-docs/chart/sparkline-light.yaml | 19 +++ .../visual-docs/output/chart-bubble-light.png | Bin 0 -> 7056 bytes .../visual-docs/output/chart-gauge-dark.png | Bin 0 -> 6615 bytes .../output/chart-progress-light.png | Bin 0 -> 8682 bytes .../output/chart-scatter-light.png | Bin 0 -> 4695 bytes .../output/chart-sparkline-light.png | Bin 0 -> 4246 bytes 12 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 examples/visual-docs/chart/bubble-light.yaml create mode 100644 examples/visual-docs/chart/gauge-dark.yaml create mode 100644 examples/visual-docs/chart/progress-light.yaml create mode 100644 examples/visual-docs/chart/scatter-light.yaml create mode 100644 examples/visual-docs/chart/sparkline-light.yaml create mode 100644 examples/visual-docs/output/chart-bubble-light.png create mode 100644 examples/visual-docs/output/chart-gauge-dark.png create mode 100644 examples/visual-docs/output/chart-progress-light.png create mode 100644 examples/visual-docs/output/chart-scatter-light.png create mode 100644 examples/visual-docs/output/chart-sparkline-light.png diff --git a/docs/wiki/Element-Reference.md b/docs/wiki/Element-Reference.md index b741449..0045579 100644 --- a/docs/wiki/Element-Reference.md +++ b/docs/wiki/Element-Reference.md @@ -1459,7 +1459,7 @@ Z # close path ## chart -A declarative chart element that renders `bar`, `line`, `area`, `pie`, and `donut` charts into a fixed `width` by `height` box. It participates in flex layout like any other element. Series data may be supplied inline or bound from the data context with `{{expressions}}` -- the same binding used by `table` rows -- so charts can be driven directly by a data file. A chart with no series (or empty data) renders a centered "no data" placeholder instead of failing. +A declarative chart element that renders `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, and `sparkline` charts into a fixed `width` by `height` box. It participates in flex layout like any other element. Series data may be supplied inline or bound from the data context with `{{expressions}}` -- the same binding used by `table` rows -- so charts can be driven directly by a data file. A chart with no series (or empty data) renders a centered "no data" placeholder instead of failing. ### Minimal Example @@ -1477,7 +1477,7 @@ A declarative chart element that renders `bar`, `line`, `area`, `pie`, and `donu | Property | YAML Name | Type | Default | Valid Values | Required | Description | |----------|-----------|------|---------|--------------|----------|-------------| -| ChartType | `chart-type` | string | `bar` | bar, line, area, pie, donut | No | Chart kind. | +| ChartType | `chart-type` | string | `bar` | bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline | No | Chart kind. | | Width | `width` | number | none | px | Yes | Chart width in pixels. | | Height | `height` | number | none | px | Yes | Chart height in pixels. | | Categories | `categories` | list | `[]` | List of strings | No | X-axis category labels (bar/line/area) or slice labels (pie/donut). | @@ -1491,6 +1491,11 @@ A declarative chart element that renders `bar`, `line`, `area`, `pie`, and `donu | Smooth | `smooth` | bool | `false` | true, false | No | **Line/area only.** Smooth (curved) lines. | | Points | `points` | bool | `false` | true, false | No | **Line/area only.** Draw point markers at each value. | | Labels | `labels` | string | `percent` | percent, value, none | No | **Pie/donut only.** Slice label content. | +| Value | `value` | number | none | Any number | No | **Gauge/progress only.** Indicator value. | +| Max | `max` | number | `100` | Any number | No | **Gauge/progress only.** Indicator maximum. | +| ValueLabel | `label` | string | none | Any string | No | **Gauge/progress only.** Centered caption under the value. | + +> **Scatter / bubble data:** supply `series[].data` as an array of tuples -- `[[x, y], ...]` for `scatter` and `[[x, y, r], ...]` for `bubble`, where `r` sizes the bubble. Tuple data is inline-only in this release (expression-bound tuples are not yet supported). Sparkline uses a plain numeric `data` array and honors `smooth`/`points`; it renders chrome-free (no axes, legend, or title). > **Resource limits:** charts are bounded by `ResourceLimits.MaxSeriesPerChart` (default `50`) and `ResourceLimits.MaxDataPointsPerSeries` (default `10000`). diff --git a/docs/wiki/Visual-Reference.md b/docs/wiki/Visual-Reference.md index e7f2c27..1cff69a 100644 --- a/docs/wiki/Visual-Reference.md +++ b/docs/wiki/Visual-Reference.md @@ -737,7 +737,7 @@ layout: ### Charts -The `chart` element renders `bar`, `line`, `area`, `pie`, and `donut` charts into a fixed `width` by `height` box. Series `data` is either an inline number array or a `{{ expression }}` bound from the data context (like `table` rows). The `palette` (named or explicit color list) and `theme` (`light`, `dark`, `minimal`) control colors. An empty/missing `series` renders a "no data" placeholder rather than an error. +The `chart` element renders `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, and `sparkline` charts into a fixed `width` by `height` box. Series `data` is either an inline number array or a `{{ expression }}` bound from the data context (like `table` rows). Scatter and bubble take tuple data (`[[x, y], ...]` / `[[x, y, r], ...]`); gauge and progress are single-value indicators driven by `value`/`max`/`label`; sparkline is a chrome-free inline line. The `palette` (named or explicit color list) and `theme` (`light`, `dark`, `minimal`) control colors. An empty/missing `series` renders a "no data" placeholder rather than an error. #### Bar @@ -846,6 +846,112 @@ layout: ![Donut chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-donut-dark.png) +#### Scatter + +Scatter plots take tuple data -- each point is an `[x, y]` pair. + +```yaml +canvas: + width: 480 + background: "#ffffff" +layout: + - type: chart + chart-type: scatter + width: 480 + height: 320 + series: + - label: cloud + data: [[1, 12], [3, 30], [5, 22], [7, 48], [9, 35]] + legend: none + palette: ocean +``` + +![Scatter chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-scatter-light.png) + +#### Bubble + +Bubble charts add a third tuple component -- `[x, y, r]` -- where `r` sizes each bubble. + +```yaml +canvas: + width: 480 + background: "#ffffff" +layout: + - type: chart + chart-type: bubble + width: 480 + height: 320 + series: + - data: [[1, 12, 6], [3, 30, 18], [5, 22, 10], [7, 48, 24]] + legend: none + palette: sunset +``` + +![Bubble chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-bubble-light.png) + +#### Gauge + +Gauge is a single-value 270-degree dial driven by `value`, `max`, and `label`. + +```yaml +canvas: + width: 280 + background: "#1e1e1e" +layout: + - type: chart + chart-type: gauge + width: 280 + height: 220 + value: 72 + max: 100 + label: CPU + theme: dark + palette: vivid +``` + +![Gauge chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-gauge-dark.png) + +#### Progress + +Progress draws a ring filled to `value` / `max`, with a centered `label`. + +```yaml +canvas: + width: 240 + background: "#ffffff" +layout: + - type: chart + chart-type: progress + width: 240 + height: 240 + value: 64 + max: 100 + label: Disk + palette: forest +``` + +![Progress chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-progress-light.png) + +#### Sparkline + +Sparkline is a tiny chrome-free line (no axes, legend, or title) that honors `smooth`/`points`. + +```yaml +canvas: + width: 200 + background: "#ffffff" +layout: + - type: chart + chart-type: sparkline + width: 200 + height: 50 + smooth: true + series: + - data: [3, 8, 4, 10, 6, 9, 5, 11] +``` + +![Sparkline chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-sparkline-light.png) + --- ## Renderer Backend Comparison diff --git a/examples/visual-docs/chart/bubble-light.yaml b/examples/visual-docs/chart/bubble-light.yaml new file mode 100644 index 0000000..665f236 --- /dev/null +++ b/examples/visual-docs/chart/bubble-light.yaml @@ -0,0 +1,20 @@ +template: + name: "chart-bubble-light" + version: 1 + +fonts: + default: "../../assets/fonts/Inter-Regular.ttf" + +canvas: + width: 480 + background: "#ffffff" + +layout: + - type: chart + chart-type: bubble + width: 480 + height: 320 + series: + - data: [[1, 12, 6], [3, 30, 18], [5, 22, 10], [7, 48, 24]] + legend: none + palette: sunset diff --git a/examples/visual-docs/chart/gauge-dark.yaml b/examples/visual-docs/chart/gauge-dark.yaml new file mode 100644 index 0000000..9f2e9d0 --- /dev/null +++ b/examples/visual-docs/chart/gauge-dark.yaml @@ -0,0 +1,21 @@ +template: + name: "chart-gauge-dark" + version: 1 + +fonts: + default: "../../assets/fonts/Inter-Regular.ttf" + +canvas: + width: 280 + background: "#1e1e1e" + +layout: + - type: chart + chart-type: gauge + width: 280 + height: 220 + value: 72 + max: 100 + label: CPU + theme: dark + palette: vivid diff --git a/examples/visual-docs/chart/progress-light.yaml b/examples/visual-docs/chart/progress-light.yaml new file mode 100644 index 0000000..c1465f5 --- /dev/null +++ b/examples/visual-docs/chart/progress-light.yaml @@ -0,0 +1,20 @@ +template: + name: "chart-progress-light" + version: 1 + +fonts: + default: "../../assets/fonts/Inter-Regular.ttf" + +canvas: + width: 240 + background: "#ffffff" + +layout: + - type: chart + chart-type: progress + width: 240 + height: 240 + value: 64 + max: 100 + label: Disk + palette: forest diff --git a/examples/visual-docs/chart/scatter-light.yaml b/examples/visual-docs/chart/scatter-light.yaml new file mode 100644 index 0000000..8f021a8 --- /dev/null +++ b/examples/visual-docs/chart/scatter-light.yaml @@ -0,0 +1,21 @@ +template: + name: "chart-scatter-light" + version: 1 + +fonts: + default: "../../assets/fonts/Inter-Regular.ttf" + +canvas: + width: 480 + background: "#ffffff" + +layout: + - type: chart + chart-type: scatter + width: 480 + height: 320 + series: + - label: cloud + data: [[1, 12], [3, 30], [5, 22], [7, 48], [9, 35]] + legend: none + palette: ocean diff --git a/examples/visual-docs/chart/sparkline-light.yaml b/examples/visual-docs/chart/sparkline-light.yaml new file mode 100644 index 0000000..9267f33 --- /dev/null +++ b/examples/visual-docs/chart/sparkline-light.yaml @@ -0,0 +1,19 @@ +template: + name: "chart-sparkline-light" + version: 1 + +fonts: + default: "../../assets/fonts/Inter-Regular.ttf" + +canvas: + width: 200 + background: "#ffffff" + +layout: + - type: chart + chart-type: sparkline + width: 200 + height: 50 + smooth: true + series: + - data: [3, 8, 4, 10, 6, 9, 5, 11] diff --git a/examples/visual-docs/output/chart-bubble-light.png b/examples/visual-docs/output/chart-bubble-light.png new file mode 100644 index 0000000000000000000000000000000000000000..e51df9ecd14eb39eae87aff884f5e5d4bd60804b GIT binary patch literal 7056 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 G+y4Uj@4=1$ literal 0 HcmV?d00001 diff --git a/examples/visual-docs/output/chart-gauge-dark.png b/examples/visual-docs/output/chart-gauge-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..40df9c913de46bfc1ade761e2de7faeab4fc1bb6 GIT binary patch literal 6615 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 McR9xV#{cp^0GCm7b^rhX literal 0 HcmV?d00001 diff --git a/examples/visual-docs/output/chart-sparkline-light.png b/examples/visual-docs/output/chart-sparkline-light.png new file mode 100644 index 0000000000000000000000000000000000000000..0396a71e60ff74a2a92ef45433520116f5143cc6 GIT binary patch literal 4246 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 literal 0 HcmV?d00001 From 5dded5650e7d6e7ff03d7340c1012e33943f1369 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:22:24 +0400 Subject: [PATCH 077/119] docs: document Phase-3 chart types in llms files Add scatter/bubble/gauge/progress/sparkline to the chart-type listings; document tuple data ([x,y]/[x,y,r]), gauge/progress value/max/label indicators, and chrome-free sparkline behavior in llms-full.txt. --- llms-full.txt | 62 ++++++++++++++++++++++++++++++++++++++++++++++----- llms.txt | 2 +- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index e68253b..380ef3a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -963,11 +963,11 @@ Example: `d: "M 0 0 L 100 50 Q 150 0 200 50 Z"` ## Element Type: chart (ChartElement) -A declarative chart element that renders bar, line, area, pie, and donut charts. The chart paints into its `width` by `height` box and participates in flex layout like any other element. Both `width` and `height` are required. +A declarative chart element that renders bar, line, area, pie, donut, scatter, bubble, gauge, progress, and sparkline charts. The chart paints into its `width` by `height` box and participates in flex layout like any other element. Both `width` and `height` are required. | Property | Type | Default | Description | |----------|------|---------|-------------| -| chart-type | string | `bar` | Chart kind: `bar`, `line`, `area`, `pie`, `donut` | +| chart-type | string | `bar` | Chart kind: `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, `sparkline` | | width | number | none | Chart width in pixels (**required**) | | height | number | none | Chart height in pixels (**required**) | | categories | string[] | `[]` | X-axis category labels (bar/line/area) or slice labels (pie/donut) | @@ -978,13 +978,16 @@ A declarative chart element that renders bar, line, area, pie, and donut charts. | title | string | none | Optional chart title | | horizontal | bool | `false` | **Bar only.** Render bars horizontally | | stacked | bool | `false` | **Bar only.** Stack series on top of each other | -| smooth | bool | `false` | **Line/area only.** Smooth (curved) lines instead of straight segments | -| points | bool | `false` | **Line/area only.** Draw point markers at each data value | +| smooth | bool | `false` | **Line/area/sparkline only.** Smooth (curved) lines instead of straight segments | +| points | bool | `false` | **Line/area/sparkline only.** Draw point markers at each data value | | labels | string | `percent` | **Pie/donut only.** Slice label content: `percent`, `value`, `none` | +| value | number | none | **Gauge/progress only.** The indicator value | +| max | number | `100` | **Gauge/progress only.** The indicator maximum | +| label | string | none | **Gauge/progress only.** Centered caption under the value | ### Series and Data Binding -Each entry in `series` is an object with an optional `label` (used in the legend) and a required `data` value. `data` is either an inline array of numbers, **or** a `{{ expression }}` string that resolves to a number array from the render data context -- the same binding mechanism used by `table` rows. This lets chart data come directly from the data file: +Each entry in `series` is an object with an optional `label` (used in the legend) and a required `data` value. For bar/line/area/pie/donut/sparkline, `data` is either an inline array of numbers, **or** a `{{ expression }}` string that resolves to a number array from the render data context -- the same binding mechanism used by `table` rows. This lets chart data come directly from the data file: ```yaml series: @@ -994,6 +997,55 @@ series: data: "{{ sales }}" # resolves to a number array from data ``` +### Phase-3 Chart Types + +**Scatter and bubble** take tuple data instead of a flat number array: `data: [[x, y], ...]` for `scatter` and `data: [[x, y, r], ...]` for `bubble`, where `r` sizes each bubble. Tuple data is inline-only (expression-bound tuples are not yet supported). + +```yaml +- type: chart + chart-type: scatter + width: 480 + height: 320 + series: + - data: [[1, 12], [3, 30], [5, 22], [7, 48], [9, 35]] +- type: chart + chart-type: bubble + width: 480 + height: 320 + series: + - data: [[1, 12, 6], [3, 30, 18], [5, 22, 10], [7, 48, 24]] +``` + +**Gauge and progress** are single-value indicators that ignore `series`/`categories` and instead use `value`, `max` (default 100), and `label` (a centered caption). Gauge draws a 270-degree dial; progress draws a ring filled to `value` / `max`. + +```yaml +- type: chart + chart-type: gauge + width: 280 + height: 220 + value: 72 + max: 100 + label: CPU +- type: chart + chart-type: progress + width: 240 + height: 240 + value: 64 + label: Disk +``` + +**Sparkline** is a tiny inline line chart with no axes, legend, or title (chrome-free). It uses a plain numeric `data` array and honors `smooth` and `points`. + +```yaml +- type: chart + chart-type: sparkline + width: 200 + height: 50 + smooth: true + series: + - data: [3, 8, 4, 10, 6, 9, 5, 11] +``` + ### Palettes and Themes The `palette` selects series colors. Use one of the named palettes (`default`, `ocean`, `sunset`, `forest`, `mono`, `vivid`) or supply an explicit list of hex colors that is applied to series/slices in order. The `theme` (`light`, `dark`, `minimal`) controls the chart's background, axis, grid, and text colors; it overrides the template-level theme for this element only. diff --git a/llms.txt b/llms.txt index 068aa0b..1462fd6 100644 --- a/llms.txt +++ b/llms.txt @@ -89,7 +89,7 @@ Sixteen element types, each a sealed class extending `TemplateElement`: | **circle** | fill (hex color OR gradient object), stroke, stroke-width, opacity, size (shorthand setting width & height = diameter) -- flex box drawn as a circle | | **ellipse** | fill (hex color OR gradient object), stroke, stroke-width, opacity -- flex box drawn as an ellipse filling its width/height | | **draw** | shapes (ordered list painted in list order); shape kinds: line, polyline, rect, circle, path (abs commands M/L/Q/C/Z); absolute coords relative to element top-left; capped by `MaxShapesPerDraw` (default 1000). Box shapes accept gradient fill objects | -| **chart** | chart-type (bar/line/area/pie/donut, default bar), width/height (required), categories (x-axis/slice labels), series (list of {label?, data}; data = inline number array OR `{{ expr }}` resolving to a number array), palette (named default/ocean/sunset/forest/mono/vivid OR explicit `["#hex",...]`), theme (light/dark/minimal override), legend (top/bottom/left/right/none, default bottom), title; bar only: horizontal, stacked; line/area: smooth, points; pie/donut: labels (percent/value/none). Empty/missing series renders a "no data" placeholder, never an error. Capped by `MaxSeriesPerChart` (50) and `MaxDataPointsPerSeries` (10000) | +| **chart** | chart-type (bar/line/area/pie/donut/scatter/bubble/gauge/progress/sparkline, default bar), width/height (required), categories (x-axis/slice labels), series (list of {label?, data}; data = inline number array, OR `[[x,y],...]`/`[[x,y,r],...]` tuples for scatter/bubble, OR `{{ expr }}` resolving to a number array), palette (named default/ocean/sunset/forest/mono/vivid OR explicit `["#hex",...]`), theme (light/dark/minimal override), legend (top/bottom/left/right/none, default bottom), title; bar only: horizontal, stacked; line/area/sparkline: smooth, points; pie/donut: labels (percent/value/none); gauge/progress only: value, max (default 100), label (centered caption). Sparkline is a chrome-free inline line. Empty/missing series renders a "no data" placeholder, never an error. Capped by `MaxSeriesPerChart` (50) and `MaxDataPointsPerSeries` (10000) | | **table** | array, as, columns (key/label/width/grow/align/format), rows, headerFont/headerFontWeight/headerFontStyle/headerFontFamily/headerColor/headerSize/headerBackground -- expands to flex tree | | **each** | array, as, children -- iteration over data arrays | | **if** | condition, equals/notEquals, then, elseIf, else -- conditional rendering | From 2c0405a8803b140d9e3bb95737458e33f04418d7 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:30:35 +0400 Subject: [PATCH 078/119] docs: add charts phase 4 implementation plan --- .../plans/2026-06-13-charts-phase4.md | 1775 +++++++++++++++++ 1 file changed, 1775 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-13-charts-phase4.md diff --git a/docs/superpowers/plans/2026-06-13-charts-phase4.md b/docs/superpowers/plans/2026-06-13-charts-phase4.md new file mode 100644 index 0000000..cad8d73 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-charts-phase4.md @@ -0,0 +1,1775 @@ +# Charts (Phase 4) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the final two chart types — `heatmap` (a grid of value-colored cells with x/y labels and optional cell values) and `radar` (category spokes with one filled+stroked closed polygon per series) — so LLM agents can render the full chart catalog from declarative YAML with zero styling decisions. + +**Architecture:** Both new types reuse the existing `ChartSeries` model. **Heatmap** treats each series as one ROW (`series[i].Data` = row *i*'s column values), `Categories` = column / x-axis labels, and a new `YLabels` property = row labels; an optional `ShowCellValues` bool draws the numeric value inside each cell. Cell color is interpolated per-value between a "low" and "high" color (derived from `palette[0]`/`palette[1]`) via a small AOT-safe `SKColor`-channel lerp. **Radar** treats each series as a closed polygon: `Categories` define N spokes radiating from center (spoke *i* at angle `-90° + 360°*i/N`, starting at top), and each series value maps radially from center (0) to the plot edge (axis max) via an `AxisScale`-derived radial mapper. New `ChartElement` props (`XLabels`, `YLabels`, `ShowCellValues`) follow the existing auto-prop + `CloneWithSubstitution` pattern; new YAML props (`x-labels`, `y-labels`, `cell-values`) register in `KnownProperties.Chart`. Rendering adds two arms to `ChartRenderer.DrawSeries` (`Heatmap` → `DrawHeatmap`, `Radar` → `DrawRadar`) reusing `AxisScale`, palette `ColorAt`, theme colors, `ColorParser`, `FormatTick`, and the existing outer `Save`/`Restore`/clip. No new packages, no new external dependencies. + +**Tech Stack:** .NET 10, C# latest, xUnit, SkiaSharp, YamlDotNet. AOT-safe (no reflection, no `dynamic`, no runtime regex), `sealed` classes, `ArgumentNullException.ThrowIfNull`, switch-based dispatch, XML docs on all public API. + +--- + +## Conventions used throughout this plan + +- All commands run from repo root `/Users/robonet/Projects/SkiaLayout`. +- Branch is already `feature/charts-and-shapes`. Do NOT create worktrees. Do NOT merge to `main`. +- Build: `dotnet build FlexRender.slnx`. Test (authoritative, net10.0): `dotnet test FlexRender.slnx --framework net10.0 --filter ...`. The net8.0 test host cannot launch in this environment — always pass `--framework net10.0`. +- NEVER pipe `dotnet` output through `tail`/`head`/`grep`. Run commands directly. +- Commit messages use Conventional Commits, NO attribution / Co-Authored-By lines. The signing key is missing — every commit uses `--no-gpg-sign`. +- After every code edit the build must be warning-free (`TreatWarningsAsErrors=true`). Watch for CA1859 (return concrete types where the concrete type is used internally), unused-parameter, and unused-using analyzer errors. +- All new public API gets XML docs. All new YAML props get registered in `KnownProperties.Chart`. Never weaken `ResourceLimits`. +- Snapshot goldens are generated by running the snapshot test once with `UPDATE_SNAPSHOTS=true dotnet test FlexRender.slnx --framework net10.0 --filter ...`, then committed. +- All code, comments, and XML docs are written in English. + +## File structure (created / modified across all tasks) + +Created: +- `src/FlexRender.Skia.Render/Rendering/HeatmapColorScale.cs` (AOT-safe two-color value→color lerp) +- Test files (one per task, see each task) + +Modified: +- `src/FlexRender.Core/Charts/ChartEnums.cs` (add `Heatmap`, `Radar` to `ChartType`) +- `src/FlexRender.Core/Parsing/Ast/ChartElement.cs` (add `XLabels`/`YLabels`/`ShowCellValues` props; clone them) +- `src/FlexRender.Yaml/Parsing/ChartParsers.cs` (parse `x-labels`, `y-labels`, `cell-values`; chart-type error message lists all types) +- `src/FlexRender.Yaml/Parsing/KnownProperties.cs` (add `x-labels`, `y-labels`, `cell-values` to `Chart` set) +- `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` (2 new `DrawSeries` arms + `DrawHeatmap`/`DrawRadar`) +- Docs: `llms.txt`, `llms-full.txt`, `docs/wiki/Element-Reference.md`, `docs/wiki/Visual-Reference.md`, + `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`, + `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` + +## Highest-risk tasks (flagged for extra review) + +1. **Task 2 + 4 — heatmap 2D-data representation + parsing.** The decision to model each series as one row (no new data structure) must coexist cleanly with the flat-`Data` path used by bar/line. `x-labels`/`y-labels` parse like `categories` (string lists). Rows may have ragged lengths; the renderer must tolerate that. No new resource limit: rows are bounded by `MaxSeriesPerChart`, columns by `MaxDataPointsPerSeries` via the existing per-series checks. +2. **Task 8 — radar polygon geometry + radial mapping.** N spokes at `-90° + 360°*i/N`; each series value maps from center (radius 0 at axis min, which is anchored at 0 by `AxisScale`) to the outer radius (axis max). Closed `SKPath` (fill semi-transparent + stroke). Degenerate cases (N < 3 categories, single series, all-zero values) must not divide by zero or throw, and every `SKPath`/`SKPaint`/`SKFont` must be disposed with balanced `Save`/`Restore`. +3. **Task 6 — heatmap color interpolation.** `HeatmapColorScale` lerps each `SKColor` channel between a low and high color. Must be deterministic for snapshots, clamp `t` into `[0, 1]`, and handle a degenerate value range (min == max → all cells use the high color). + +--- + +## Task 1: Extend ChartType enum with Heatmap and Radar + +**Files:** +- Modify: `src/FlexRender.Core/Charts/ChartEnums.cs` +- Test: `tests/FlexRender.Tests/Charts/ChartTypePhase4Tests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Charts/ChartTypePhase4Tests.cs`: + +```csharp +using System; +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Sanity tests for the Phase-4 chart type enum members. +/// +public sealed class ChartTypePhase4Tests +{ + [Theory] + [InlineData("Heatmap")] + [InlineData("Radar")] + public void ChartType_HasAllPhase4Members(string name) + { + Assert.True(Enum.TryParse(name, out _)); + } + + [Theory] + [InlineData("Bar")] + [InlineData("Scatter")] + [InlineData("Gauge")] + [InlineData("Sparkline")] + public void ChartType_StillHasEarlierMembers(string name) + { + Assert.True(Enum.TryParse(name, out _)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartTypePhase4Tests"` +Expected: FAIL — `Heatmap`/`Radar` not defined (the two Phase-4 cases parse to false). + +- [ ] **Step 3: Add the enum members** + +In `src/FlexRender.Core/Charts/ChartEnums.cs`, the `ChartType` enum currently ends with `Sparkline`. Add a comma after `Sparkline` and append the two new members so the enum tail reads: + +```csharp + /// Tiny inline line chart with no axes, labels, or legend. + Sparkline, + + /// Grid of value-colored cells (2D data: rows = series, columns = categories). + Heatmap, + + /// Radar (spider) chart: category spokes with one closed polygon per series. + Radar +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartTypePhase4Tests"` +Expected: PASS (6 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Charts/ChartEnums.cs tests/FlexRender.Tests/Charts/ChartTypePhase4Tests.cs +git commit --no-gpg-sign -m "feat(charts): add heatmap and radar chart types" +``` + +--- + +## Task 2: ChartElement gains XLabels / YLabels / ShowCellValues + +Heatmap needs explicit column (x) and row (y) labels plus an opt-in to draw the numeric value inside each cell. `XLabels` falls back to `Categories` at render time when empty (so `categories:` keeps working as the x-axis). Radar reuses `Categories` and `Series` only — no new props. + +**Files:** +- Modify: `src/FlexRender.Core/Parsing/Ast/ChartElement.cs` +- Test: `tests/FlexRender.Tests/Parsing/Ast/ChartElementHeatmapTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Ast/ChartElementHeatmapTests.cs`: + +```csharp +using System; +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the heatmap label/cell-value properties on . +/// +public sealed class ChartElementHeatmapTests +{ + [Fact] + public void Defaults_LabelsEmpty_CellValuesFalse() + { + var chart = new ChartElement(ChartType.Heatmap, new List()); + Assert.Empty(chart.XLabels); + Assert.Empty(chart.YLabels); + Assert.False(chart.ShowCellValues); + } + + [Fact] + public void CloneWithSubstitution_PreservesLabelsAndCellValues() + { + var chart = new ChartElement(ChartType.Heatmap, new List()) + { + XLabels = new[] { "Mon", "Tue" }, + YLabels = new[] { "AM", "PM" }, + ShowCellValues = true + }; + + var clone = (ChartElement)chart.CloneWithSubstitution(s => s); + + Assert.Equal(new[] { "Mon", "Tue" }, clone.XLabels); + Assert.Equal(new[] { "AM", "PM" }, clone.YLabels); + Assert.True(clone.ShowCellValues); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementHeatmapTests"` +Expected: BUILD FAILURE — `XLabels`/`YLabels`/`ShowCellValues` not defined. + +- [ ] **Step 3: Add the properties + clone** + +In `src/FlexRender.Core/Parsing/Ast/ChartElement.cs`, add these three properties after the `ValueLabel` property (the last property before `CloneWithSubstitution`): + +```csharp + /// Gets or sets the heatmap column (x-axis) labels. Empty falls back to . + public IReadOnlyList XLabels { get; set; } = Array.Empty(); + + /// Gets or sets the heatmap row (y-axis) labels, one per series/row. + public IReadOnlyList YLabels { get; set; } = Array.Empty(); + + /// Gets or sets whether the heatmap draws each cell's numeric value inside the cell. + public bool ShowCellValues { get; set; } +``` + +In `CloneWithSubstitution`, add the three to the object initializer (after `ValueLabel = ValueLabel`): + +```csharp + ValueLabel = ValueLabel, + XLabels = XLabels, + YLabels = YLabels, + ShowCellValues = ShowCellValues +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementHeatmapTests"` +Expected: PASS (2 cases). + +- [ ] **Step 5: Verify earlier ChartElement tests still pass** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartElementGaugeTests"` +Expected: PASS (no regression to the Phase-3 clone properties). + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Ast/ChartElement.cs tests/FlexRender.Tests/Parsing/Ast/ChartElementHeatmapTests.cs +git commit --no-gpg-sign -m "feat(ast): add XLabels/YLabels/ShowCellValues to ChartElement for heatmap" +``` + +--- + +## Task 3: Parse x-labels / y-labels / cell-values + register KnownProperties + chart-type error message + +`x-labels` and `y-labels` parse exactly like `categories` (a sequence of scalar strings). `cell-values` is a bool. The `chart-type` error message must list `heatmap` and `radar`. + +**Files:** +- Modify: `src/FlexRender.Yaml/Parsing/ChartParsers.cs` +- Modify: `src/FlexRender.Yaml/Parsing/KnownProperties.cs` +- Test: `tests/FlexRender.Tests/Parsing/ChartHeatmapParsersTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/ChartHeatmapParsersTests.cs`: + +```csharp +using FlexRender.Charts; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for parsing heatmap labels / cell-values, radar series, and the updated chart-type validation. +/// +public sealed class ChartHeatmapParsersTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_Heatmap_ReadsLabelsAndCellValues() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: heatmap + width: 400 + height: 300 + x-labels: [Mon, Tue, Wed] + y-labels: [AM, PM] + cell-values: true + series: + - data: [1, 2, 3] + - data: [4, 5, 6] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal(ChartType.Heatmap, chart.ChartType); + Assert.Equal(new[] { "Mon", "Tue", "Wed" }, chart.XLabels); + Assert.Equal(new[] { "AM", "PM" }, chart.YLabels); + Assert.True(chart.ShowCellValues); + Assert.Equal(2, chart.Series.Count); + Assert.Equal(3, chart.Series[0].Data.Count); + } + + [Fact] + public void Parse_Heatmap_DefaultsLabelsEmptyCellValuesFalse() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: heatmap + width: 400 + height: 300 + series: + - data: [1, 2] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Empty(chart.XLabels); + Assert.Empty(chart.YLabels); + Assert.False(chart.ShowCellValues); + } + + [Fact] + public void Parse_Radar_ReadsCategoriesAndSeries() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: radar + width: 400 + height: 400 + categories: [Speed, Power, Range, Agility, Armor] + series: + - label: A + data: [4, 3, 5, 2, 4] + - label: B + data: [3, 5, 2, 4, 3] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal(ChartType.Radar, chart.ChartType); + Assert.Equal(5, chart.Categories.Count); + Assert.Equal(2, chart.Series.Count); + Assert.Equal(5, chart.Series[0].Data.Count); + } + + [Fact] + public void Parse_TypoInXLabels_Throws() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: heatmap + width: 400 + height: 300 + x-labesl: [Mon, Tue] + series: + - data: [1, 2] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("x-labesl", ex.Message); + } + + [Fact] + public void Parse_UnknownChartType_ErrorListsHeatmapAndRadar() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: nope + width: 400 + height: 300 + series: + - data: [1, 2] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("heatmap", ex.Message); + Assert.Contains("radar", ex.Message); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHeatmapParsersTests"` +Expected: FAIL — `x-labels`/`y-labels`/`cell-values` are unknown properties (parser rejects them) and not yet read onto the element; the chart-type error message omits `heatmap`/`radar`. + +- [ ] **Step 3: Register the new props in KnownProperties** + +In `src/FlexRender.Yaml/Parsing/KnownProperties.cs`, the `Chart` set, add `"x-labels", "y-labels", "cell-values"` to the list. The set currently reads (Phase-3 state): + +```csharp + internal static readonly HashSet Chart = BuildSet(FlexItemProperties, + [ + "chart-type", "categories", "series", "palette", "theme", "legend", "title", + "horizontal", "stacked", "smooth", "points", "labels", + "value", "max", "label", + "background", "rotate", "padding", "margin" + ]); +``` + +Replace it with (insert the new line after the `value`/`max`/`label` line): + +```csharp + internal static readonly HashSet Chart = BuildSet(FlexItemProperties, + [ + "chart-type", "categories", "series", "palette", "theme", "legend", "title", + "horizontal", "stacked", "smooth", "points", "labels", + "value", "max", "label", + "x-labels", "y-labels", "cell-values", + "background", "rotate", "padding", "margin" + ]); +``` + +> If the actual file's `Chart` initializer differs slightly from the snippet above (e.g. property order), only add the three new strings `"x-labels"`, `"y-labels"`, `"cell-values"` to the existing collection — do not reorder or remove anything. + +- [ ] **Step 4: Parse the new props + update the chart-type error message** + +In `src/FlexRender.Yaml/Parsing/ChartParsers.cs`: + +1. Replace the `chart-type` error message in `ParseChartType` so it lists every type: + +```csharp + throw new TemplateParseException( + $"Unknown chart-type '{raw}'. Valid values: bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline, heatmap, radar."); +``` + +2. In `ParseChartElement`'s object initializer, add three lines after `ValueLabel = GetStringValue(node, "label"),`: + +```csharp + ValueLabel = GetStringValue(node, "label"), + XLabels = ParseStringList(node, "x-labels"), + YLabels = ParseStringList(node, "y-labels"), + ShowCellValues = GetBoolValue(node, "cell-values", false), +``` + +3. Add a reusable string-list parser. The existing `ParseCategories` reads `categories` specifically; generalize by adding a `ParseStringList` helper next to it (do NOT delete `ParseCategories` — `Categories` still uses it). Add after `ParseCategories`: + +```csharp + /// + /// Parses an optional sequence of scalar string labels under the given key (heatmap x/y labels). + /// + /// The chart mapping node. + /// The property key (e.g. "x-labels"). + /// The labels in order; empty when the key is absent. + private static List ParseStringList(YamlMappingNode node, string key) + { + var labels = new List(); + if (TryGetSequence(node, key, out var seq)) + { + foreach (var item in seq.Children) + { + if (item is YamlScalarNode scalar && scalar.Value is not null) + labels.Add(scalar.Value); + } + } + return labels; + } +``` + +Note: `GetBoolValue`, `TryGetSequence`, `GetStringValue` are already imported via the `static ... YamlPropertyHelpers` using; `YamlScalarNode` / `YamlSequenceNode` via `YamlDotNet.RepresentationModel`. `List` is assignable to the `IReadOnlyList` setters on `ChartElement`. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHeatmapParsersTests"` +Expected: PASS (5 cases). + +- [ ] **Step 6: Verify earlier parser + KnownProperties tests still pass** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartParsersTests|FullyQualifiedName~ChartKnownPropertiesTests|FullyQualifiedName~ChartScatterParsersTests"` +Expected: PASS (no regression; existing `categories`/`series`/tuple parsing unaffected). + +- [ ] **Step 7: Commit** + +```bash +git add src/FlexRender.Yaml/Parsing/ChartParsers.cs src/FlexRender.Yaml/Parsing/KnownProperties.cs tests/FlexRender.Tests/Parsing/ChartHeatmapParsersTests.cs +git commit --no-gpg-sign -m "feat(yaml): parse heatmap x-labels/y-labels/cell-values and register chart props" +``` + +--- + +## Task 4: HasAnyData already covers heatmap/radar — verify, no code change expected + +Heatmap and radar both use flat `Data` series, which `HasAnyData` already treats as "has data" (`s.Data.Count > 0`). This task is a guard: confirm an empty heatmap/radar renders the no-data placeholder and a non-empty one does not short-circuit. No production code change is expected; if a change *is* needed, this task documents it. + +**Files:** +- Test: `tests/FlexRender.Tests/Rendering/ChartHeatmapRadarDataTests.cs` (create) + +- [ ] **Step 1: Write the test** + +Create `tests/FlexRender.Tests/Rendering/ChartHeatmapRadarDataTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies empty heatmap/radar charts draw the no-data placeholder and non-empty ones draw content. +/// +public sealed class ChartHeatmapRadarDataTests +{ + [Fact] + public void EmptyHeatmap_DrawsNoDataPlaceholder() + { + var chart = new ChartElement(ChartType.Heatmap, new List()) + { + Legend = LegendPosition.None, + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(200, 150, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 150f, typeface: null, antialias: true); + + Assert.True(HasNonWhitePixel(bitmap), "Expected the no-data border to draw."); + } + + [Fact] + public void EmptyRadar_DrawsNoDataPlaceholder() + { + var chart = new ChartElement(ChartType.Radar, new List()) + { + Legend = LegendPosition.None, + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: true); + + Assert.True(HasNonWhitePixel(bitmap), "Expected the no-data border to draw."); + } + + private static bool HasNonWhitePixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red < 240 || p.Green < 240 || p.Blue < 240) + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Run the test** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHeatmapRadarDataTests"` +Expected: PASS (2 cases). `HasAnyData` returns false for empty series, so `Draw` calls `DrawNoData`, which strokes a dashed border — non-white pixels appear. No production change needed. + +> If a case unexpectedly FAILS (blank bitmap), it means `HasAnyData` short-circuited incorrectly; in that case inspect `HasAnyData` in `ChartRenderer.cs` and confirm it returns false only when *all* series have empty `Data` and empty `Points` (it should already). Do not weaken the gauge/progress branch. + +- [ ] **Step 3: Commit** + +```bash +git add tests/FlexRender.Tests/Rendering/ChartHeatmapRadarDataTests.cs +git commit --no-gpg-sign -m "test(skia): no-data placeholder for empty heatmap and radar" +``` + +--- + +## Task 5: HeatmapColorScale — AOT-safe two-color value→color lerp + +A small pure helper maps a value in `[min, max]` to an `SKColor` interpolated channel-by-channel between a `low` and `high` color. Deterministic (no randomness), clamps the interpolation parameter, and handles `min == max` (returns the high color). + +**Files:** +- Create: `src/FlexRender.Skia.Render/Rendering/HeatmapColorScale.cs` +- Test: `tests/FlexRender.Tests/Rendering/HeatmapColorScaleTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/HeatmapColorScaleTests.cs`: + +```csharp +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Tests for the heatmap two-color value-to-color interpolation. +/// +public sealed class HeatmapColorScaleTests +{ + private static readonly SKColor Low = new(0, 0, 0); // black + private static readonly SKColor High = new(200, 100, 50); // warm + + [Fact] + public void Map_AtMin_ReturnsLowColor() + { + var c = HeatmapColorScale.Map(0d, 0d, 10d, Low, High); + Assert.Equal(Low.Red, c.Red); + Assert.Equal(Low.Green, c.Green); + Assert.Equal(Low.Blue, c.Blue); + } + + [Fact] + public void Map_AtMax_ReturnsHighColor() + { + var c = HeatmapColorScale.Map(10d, 0d, 10d, Low, High); + Assert.Equal(High.Red, c.Red); + Assert.Equal(High.Green, c.Green); + Assert.Equal(High.Blue, c.Blue); + } + + [Fact] + public void Map_Midpoint_IsBetweenLowAndHigh() + { + var c = HeatmapColorScale.Map(5d, 0d, 10d, Low, High); + Assert.Equal((byte)100, c.Red); // (0 + 200) / 2 + Assert.Equal((byte)50, c.Green); // (0 + 100) / 2 + Assert.Equal((byte)25, c.Blue); // (0 + 50) / 2 + } + + [Fact] + public void Map_DegenerateRange_ReturnsHighColor() + { + var c = HeatmapColorScale.Map(5d, 5d, 5d, Low, High); + Assert.Equal(High.Red, c.Red); + Assert.Equal(High.Green, c.Green); + Assert.Equal(High.Blue, c.Blue); + } + + [Fact] + public void Map_BelowMin_ClampsToLow() + { + var c = HeatmapColorScale.Map(-5d, 0d, 10d, Low, High); + Assert.Equal(Low.Red, c.Red); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~HeatmapColorScaleTests"` +Expected: BUILD FAILURE — `HeatmapColorScale` not defined. + +- [ ] **Step 3: Create the helper** + +Create `src/FlexRender.Skia.Render/Rendering/HeatmapColorScale.cs`: + +```csharp +using System; +using SkiaSharp; + +namespace FlexRender.Rendering; + +/// +/// Maps a numeric value within a data range to a color interpolated channel-by-channel between a +/// "low" and a "high" color. Pure and deterministic (AOT-safe, no allocations, no randomness), +/// suitable for snapshot-stable heatmap cell coloring. +/// +internal static class HeatmapColorScale +{ + /// + /// Maps in .. to a color + /// linearly interpolated between (at ) and + /// (at ). The interpolation parameter is clamped + /// to [0, 1]; a degenerate range ( == ) + /// yields . + /// + /// The cell value. + /// The data minimum (maps to ). + /// The data maximum (maps to ). + /// The color at the low end of the range. + /// The color at the high end of the range. + /// The interpolated, fully opaque color. + public static SKColor Map(double value, double min, double max, SKColor low, SKColor high) + { + var span = max - min; + var t = span <= 0d ? 1d : Math.Clamp((value - min) / span, 0d, 1d); + + var r = Lerp(low.Red, high.Red, t); + var g = Lerp(low.Green, high.Green, t); + var b = Lerp(low.Blue, high.Blue, t); + return new SKColor(r, g, b); + } + + /// Linearly interpolates a single 0..255 channel and rounds to the nearest byte. + /// The channel value at t = 0. + /// The channel value at t = 1. + /// The interpolation parameter in [0, 1]. + /// The interpolated channel as a byte. + private static byte Lerp(byte a, byte b, double t) + { + var v = a + ((b - a) * t); + return (byte)Math.Clamp(Math.Round(v), 0d, 255d); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~HeatmapColorScaleTests"` +Expected: PASS (5 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/HeatmapColorScale.cs tests/FlexRender.Tests/Rendering/HeatmapColorScaleTests.cs +git commit --no-gpg-sign -m "feat(skia): add heatmap two-color value-to-color interpolation" +``` + +--- + +## Task 6: Renderer — heatmap grid + +Each series is one row; `Categories` (or `XLabels` when set) are columns. Compute the global min/max across all cells, then for each cell fill a rect with `HeatmapColorScale.Map(...)` using `low = palette[0]`, `high = palette[1]` (or `palette[0]` again when the palette has one color). Draw x-labels under the grid, y-labels left of the grid, and (when `ShowCellValues`) the value centered in each cell. + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Test: `tests/FlexRender.Tests/Rendering/ChartHeatmapRenderTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ChartHeatmapRenderTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies the heatmap draws value-colored cells. +/// +public sealed class ChartHeatmapRenderTests +{ + [Fact] + public void Heatmap_DrawsHighValueCellInHighColor() + { + // Two rows of two cells; the max value (40) cell must paint the palette "high" color (red). + var chart = new ChartElement(ChartType.Heatmap, new List + { + ChartSeries.FromInline("r0", new[] { 0d, 10d }), + ChartSeries.FromInline("r1", new[] { 20d, 40d }) + }) + { + Legend = LegendPosition.None, + // low = white, high = red, so the highest cell is red and the lowest is white. + Palette = new ChartPalette(new[] { "#ffffff", "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: false); + + Assert.True(HasRedPixel(bitmap), "Expected the highest-value cell to be red."); + } + + private static bool HasRedPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 180 && p.Green < 100 && p.Blue < 100) + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHeatmapRenderTests"` +Expected: FAIL — the `default` arm of `DrawSeries` draws only a faint gray border, no red cell. + +- [ ] **Step 3: Add the Heatmap case + method** + +In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`, add a case to the `DrawSeries` switch (before `default:`): + +```csharp + case ChartType.Heatmap: + DrawHeatmap(canvas, chart, theme, width, height, typeface, antialias); + break; +``` + +Then add the method (place it after `DrawProgress`, before `BuildSeriesPath`): + +```csharp + /// + /// Draws a heatmap: each series is one row and each column is a category. Cell color encodes the + /// value, interpolated between the palette's first ("low") and second ("high") colors. Optional + /// x/y labels and per-cell value text are drawn when a typeface is available. + /// + private static void DrawHeatmap( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + var rowCount = chart.Series.Count; + if (rowCount == 0) + return; + + // Columns = the widest row; ragged rows simply leave trailing cells unpainted. + var colCount = 0; + foreach (var s in chart.Series) + colCount = Math.Max(colCount, s.Data.Count); + if (colCount == 0) + return; + + // Global value range across every cell, for the color scale. + var (minV, maxV) = DataBounds(chart); + + var palette = chart.Palette ?? ChartPalettes.Default; + var lowColor = ColorParser.Parse(palette.ColorAt(0)); + var highColor = ColorParser.Parse(palette.Colors.Count > 1 ? palette.ColorAt(1) : palette.ColorAt(0)); + + // X labels: prefer XLabels, fall back to Categories. + var xLabels = chart.XLabels.Count > 0 ? chart.XLabels : chart.Categories; + var yLabels = chart.YLabels; + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var top = hasTitle ? theme.TitleSize + 8f : 4f; + // Reserve a left gutter for y-labels and a bottom gutter for x-labels (only when labelled). + var leftGutter = typeface is not null && yLabels.Count > 0 ? 52f : 6f; + var bottomGutter = typeface is not null && xLabels.Count > 0 ? theme.LabelSize + 8f : 6f; + + var gridLeft = leftGutter; + var gridTop = top; + var gridRight = width - 6f; + var gridBottom = height - bottomGutter; + if (gridRight <= gridLeft || gridBottom <= gridTop) + return; + + var cellW = (gridRight - gridLeft) / colCount; + var cellH = (gridBottom - gridTop) / rowCount; + + SKFont? font = null; + SKPaint? labelPaint = null; + if (typeface is not null) + { + font = new SKFont(typeface, theme.LabelSize); + labelPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + } + + try + { + using var cellPaint = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = antialias }; + using var cellValuePaint = new SKPaint { IsAntialias = antialias }; + + for (var ri = 0; ri < rowCount; ri++) + { + var data = chart.Series[ri].Data; + var cellTop = gridTop + (cellH * ri); + + for (var ci = 0; ci < data.Count; ci++) + { + var value = data[ci]; + var cellLeft = gridLeft + (cellW * ci); + cellPaint.Color = HeatmapColorScale.Map(value, minV, maxV, lowColor, highColor); + // Inset by half a pixel to leave a faint grid seam between cells. + canvas.DrawRect(cellLeft + 0.5f, cellTop + 0.5f, cellW - 1f, cellH - 1f, cellPaint); + + if (chart.ShowCellValues && font is not null) + { + // Choose dark or light value text for contrast against the cell color. + var cell = cellPaint.Color; + var luminance = (0.299f * cell.Red) + (0.587f * cell.Green) + (0.114f * cell.Blue); + cellValuePaint.Color = luminance > 140f ? SKColors.Black : SKColors.White; + + var text = FormatTick(value); + var tw = font.MeasureText(text); + var tx = cellLeft + ((cellW - tw) / 2f); + var ty = cellTop + ((cellH + theme.LabelSize) / 2f) - 2f; + canvas.DrawText(text, tx, ty, SKTextAlign.Left, font, cellValuePaint); + } + } + } + + if (font is not null && labelPaint is not null) + { + for (var ci = 0; ci < xLabels.Count && ci < colCount; ci++) + { + var label = xLabels[ci]; + var tw = font.MeasureText(label); + var cx = gridLeft + (cellW * (ci + 0.5f)); + canvas.DrawText(label, cx - (tw / 2f), gridBottom + theme.LabelSize + 2f, SKTextAlign.Left, font, labelPaint); + } + + for (var ri = 0; ri < yLabels.Count && ri < rowCount; ri++) + { + var label = yLabels[ri]; + var tw = font.MeasureText(label); + var cy = gridTop + (cellH * (ri + 0.5f)) + (theme.LabelSize / 3f); + canvas.DrawText(label, gridLeft - tw - 6f, cy, SKTextAlign.Left, font, labelPaint); + } + } + } + finally + { + font?.Dispose(); + labelPaint?.Dispose(); + } + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartHeatmapRenderTests"` +Expected: PASS (1 case). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartHeatmapRenderTests.cs +git commit --no-gpg-sign -m "feat(skia): render heatmap charts" +``` + +--- + +## Task 7: Renderer — radial spoke geometry helper (RadarGeometry) + +Radar needs to convert (spoke index, radial fraction) into a pixel point and to know each spoke's angle. Extract a tiny pure helper so the geometry is unit-testable without rendering. It lives in `FlexRender.Core/Charts/` next to the other math. + +**Files:** +- Create: `src/FlexRender.Core/Charts/RadarGeometry.cs` +- Test: `tests/FlexRender.Tests/Charts/RadarGeometryTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Charts/RadarGeometryTests.cs`: + +```csharp +using System; +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for radar spoke geometry: angles start at the top and advance clockwise. +/// +public sealed class RadarGeometryTests +{ + [Fact] + public void SpokeAngle_FirstSpoke_PointsUp() + { + // Spoke 0 of 4 is straight up: -90 degrees == -PI/2 radians. + var angle = RadarGeometry.SpokeAngle(0, 4); + Assert.Equal(-MathF.PI / 2f, angle, 4); + } + + [Fact] + public void SpokeAngle_SecondOfFour_PointsRight() + { + // Spoke 1 of 4 is to the right: 0 radians. + var angle = RadarGeometry.SpokeAngle(1, 4); + Assert.Equal(0f, angle, 4); + } + + [Fact] + public void Project_ZeroFraction_ReturnsCenter() + { + var p = RadarGeometry.Project(centerX: 100f, centerY: 100f, radius: 50f, spokeIndex: 0, spokeCount: 4, fraction: 0f); + Assert.Equal(100f, p.X, 3); + Assert.Equal(100f, p.Y, 3); + } + + [Fact] + public void Project_FullFractionFirstSpoke_ReachesTopEdge() + { + var p = RadarGeometry.Project(centerX: 100f, centerY: 100f, radius: 50f, spokeIndex: 0, spokeCount: 4, fraction: 1f); + Assert.Equal(100f, p.X, 3); // straight up: x unchanged + Assert.Equal(50f, p.Y, 3); // y = 100 - 50 + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~RadarGeometryTests"` +Expected: BUILD FAILURE — `RadarGeometry` not defined. + +- [ ] **Step 3: Create the helper** + +Create `src/FlexRender.Core/Charts/RadarGeometry.cs`: + +```csharp +using System; + +namespace FlexRender.Charts; + +/// +/// A 2D point in pixel space, returned by so the Core math layer stays +/// independent of any rendering library. +/// +/// The pixel X. +/// The pixel Y. +public readonly record struct RadarPoint(float X, float Y); + +/// +/// Pure, renderer-agnostic geometry for radar (spider) charts. Spoke 0 points straight up and +/// successive spokes advance clockwise; a value's radial fraction (0 at center, 1 at the outer +/// radius) projects onto its spoke. +/// +public static class RadarGeometry +{ + /// + /// Returns the angle (in radians) of a spoke, measured from the positive X axis with screen Y + /// growing downward. Spoke 0 is at -PI/2 (straight up); spokes advance clockwise. + /// + /// The zero-based spoke index. + /// The total number of spokes (must be positive). + /// The spoke angle in radians. + public static float SpokeAngle(int spokeIndex, int spokeCount) + { + if (spokeCount <= 0) + return -MathF.PI / 2f; + return (-MathF.PI / 2f) + ((2f * MathF.PI) * spokeIndex / spokeCount); + } + + /// + /// Projects a radial fraction onto a spoke, returning the pixel point. + /// + /// The radar center X in pixels. + /// The radar center Y in pixels. + /// The outer radius in pixels (fraction 1 maps here). + /// The zero-based spoke index. + /// The total number of spokes. + /// The radial fraction in [0, 1] (clamped). + /// The projected pixel point. + public static RadarPoint Project( + float centerX, float centerY, float radius, int spokeIndex, int spokeCount, float fraction) + { + var f = Math.Clamp(fraction, 0f, 1f); + var angle = SpokeAngle(spokeIndex, spokeCount); + var r = radius * f; + return new RadarPoint( + centerX + (r * MathF.Cos(angle)), + centerY + (r * MathF.Sin(angle))); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~RadarGeometryTests"` +Expected: PASS (4 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Core/Charts/RadarGeometry.cs tests/FlexRender.Tests/Charts/RadarGeometryTests.cs +git commit --no-gpg-sign -m "feat(charts): add radar spoke geometry helper" +``` + +--- + +## Task 8: Renderer — radar polygons + +Radar draws concentric grid rings, spoke lines + category labels, and one closed polygon per series (filled semi-transparent + stroked). The radial scale comes from an `AxisScale` over the data range; values map from center (axis min, anchored at 0) to the outer radius (axis max) via `RadarGeometry.Project` with `fraction = (value - scale.Min) / (scale.Max - scale.Min)`. + +**Files:** +- Modify: `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs` +- Test: `tests/FlexRender.Tests/Rendering/ChartRadarRenderTests.cs` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Rendering/ChartRadarRenderTests.cs`: + +```csharp +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies radar draws a palette-colored polygon for each series. +/// +public sealed class ChartRadarRenderTests +{ + [Fact] + public void Radar_DrawsColoredPolygon() + { + var chart = new ChartElement(ChartType.Radar, new List + { + ChartSeries.FromInline("A", new[] { 4d, 3d, 5d, 2d, 4d }) + }) + { + Categories = new[] { "Speed", "Power", "Range", "Agility", "Armor" }, + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(260, 260, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 260f, 260f, typeface: null, antialias: true); + + Assert.True(HasReddishPixel(bitmap), "Expected a red radar polygon (fill or stroke)."); + } + + [Fact] + public void Radar_FewerThanThreeCategories_DoesNotThrow() + { + var chart = new ChartElement(ChartType.Radar, new List + { + ChartSeries.FromInline("A", new[] { 4d, 3d }) + }) + { + Categories = new[] { "X", "Y" }, + Legend = LegendPosition.None, + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + var ex = Record.Exception(() => + ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: true)); + Assert.Null(ex); + } + + private static bool HasReddishPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + // The fill is semi-transparent red over white, so allow elevated red with lower G/B. + if (p.Red > 200 && p.Green < 200 && p.Blue < 200 && (p.Red - p.Green) > 30) + return true; + } + return false; + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRadarRenderTests"` +Expected: FAIL — `Radar_DrawsColoredPolygon` finds no reddish pixel (the `default` border arm draws axis-color gray only). The `FewerThanThreeCategories` case may already pass via the gray border, but the colored-polygon case fails. + +- [ ] **Step 3: Add the Radar case + method** + +In `src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs`: + +1. Ensure `using FlexRender.Charts;` is present (it already is — `RadarGeometry`/`RadarPoint` live in that namespace). + +2. Add a case to the `DrawSeries` switch (before `default:`): + +```csharp + case ChartType.Radar: + DrawRadar(canvas, chart, theme, width, height, typeface, antialias); + break; +``` + +3. Add the method after `DrawHeatmap`: + +```csharp + /// + /// Draws a radar (spider) chart: N spokes for N categories, concentric grid rings, and one + /// closed polygon per series filled semi-transparently and stroked. Values map radially from + /// the center (axis minimum, anchored at zero) to the outer radius (axis maximum). + /// + private static void DrawRadar( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + var spokeCount = chart.Categories.Count; + // Without categories, fall back to the longest series length so a polygon can still draw. + if (spokeCount == 0) + { + foreach (var s in chart.Series) + spokeCount = Math.Max(spokeCount, s.Data.Count); + } + if (spokeCount == 0) + return; + + var (dataMin, dataMax) = DataBounds(chart); + var scale = AxisScale.Compute(dataMin, dataMax, targetTicks: 4); + var span = scale.Max - scale.Min; + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var legendReserve = chart.Legend is LegendPosition.Bottom ? 28f : 0f; + var top = hasTitle ? theme.TitleSize + 8f : 8f; + var availH = height - top - legendReserve - 8f; + var availW = width - 16f; + if (availH <= 0f || availW <= 0f) + return; + + // Leave room for category labels around the rim. + var labelRoom = typeface is not null && spokeCount > 0 ? theme.LabelSize + 6f : 0f; + var radius = (MathF.Min(availW, availH) / 2f) - labelRoom; + if (radius <= 0f) + return; + + var cx = width / 2f; + var cy = top + (availH / 2f); + + DrawRadarGrid(canvas, theme, cx, cy, radius, spokeCount, antialias); + DrawRadarSpokeLabels(canvas, chart, theme, cx, cy, radius, spokeCount, typeface, antialias); + + var palette = chart.Palette ?? ChartPalettes.Default; + + for (var si = 0; si < chart.Series.Count; si++) + { + var data = chart.Series[si].Data; + if (data.Count == 0) + continue; + + var color = ColorParser.Parse(palette.ColorAt(si)); + + using var path = new SKPath(); + for (var i = 0; i < spokeCount; i++) + { + var value = i < data.Count ? data[i] : scale.Min; + var fraction = span <= 0d ? 0f : (float)((value - scale.Min) / span); + var p = RadarGeometry.Project(cx, cy, radius, i, spokeCount, fraction); + if (i == 0) + path.MoveTo(p.X, p.Y); + else + path.LineTo(p.X, p.Y); + } + path.Close(); + + using var fillPaint = new SKPaint + { + Color = color.WithAlpha(70), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + canvas.DrawPath(path, fillPaint); + + using var strokePaint = new SKPaint + { + Color = color, + Style = SKPaintStyle.Stroke, + StrokeWidth = theme.LineWidth, + IsAntialias = antialias, + StrokeJoin = SKStrokeJoin.Round + }; + canvas.DrawPath(path, strokePaint); + } + } + + /// Draws the radar's concentric grid rings and radial spoke lines. + private static void DrawRadarGrid( + SKCanvas canvas, ChartTheme theme, float cx, float cy, float radius, int spokeCount, bool antialias) + { + using var gridPaint = new SKPaint + { + Color = ColorParser.Parse(theme.GridColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = 1f, + IsAntialias = antialias + }; + + const int rings = 4; + for (var ring = 1; ring <= rings; ring++) + { + var r = radius * ring / rings; + using var ringPath = new SKPath(); + for (var i = 0; i < spokeCount; i++) + { + var p = RadarGeometry.Project(cx, cy, r, i, spokeCount, 1f); + if (i == 0) + ringPath.MoveTo(p.X, p.Y); + else + ringPath.LineTo(p.X, p.Y); + } + ringPath.Close(); + canvas.DrawPath(ringPath, gridPaint); + } + + for (var i = 0; i < spokeCount; i++) + { + var p = RadarGeometry.Project(cx, cy, radius, i, spokeCount, 1f); + canvas.DrawLine(cx, cy, p.X, p.Y, gridPaint); + } + } + + /// Draws each category label just outside its spoke's outer end, when a typeface is available. + private static void DrawRadarSpokeLabels( + SKCanvas canvas, ChartElement chart, ChartTheme theme, float cx, float cy, float radius, + int spokeCount, SKTypeface? typeface, bool antialias) + { + if (typeface is null || chart.Categories.Count == 0) + return; + + using var font = new SKFont(typeface, theme.LabelSize); + using var paint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + + for (var i = 0; i < spokeCount && i < chart.Categories.Count; i++) + { + var label = chart.Categories[i]; + // Project slightly past the rim so the text sits outside the outer ring. + var p = RadarGeometry.Project(cx, cy, radius + (theme.LabelSize * 0.6f), i, spokeCount, 1f); + var tw = font.MeasureText(label); + // Center the text horizontally on its anchor; nudge vertically by a third of the label size. + canvas.DrawText(label, p.X - (tw / 2f), p.Y + (theme.LabelSize / 3f), SKTextAlign.Left, font, paint); + } + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRadarRenderTests"` +Expected: PASS (2 cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs tests/FlexRender.Tests/Rendering/ChartRadarRenderTests.cs +git commit --no-gpg-sign -m "feat(skia): render radar charts" +``` + +--- + +## Task 9: End-to-end smoke tests via the YAML pipeline + +Verify heatmap and radar render through the full parse → measure → render path (not just direct `ChartRenderer.Draw`). + +**Files:** +- Modify: `tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs` + +- [ ] **Step 1: Add the failing smoke tests** + +Append these two methods inside the `ChartRenderSmokeTests` class (before its closing brace). They reuse the existing private `Render`/`HasNonBackgroundPixel` helpers: + +```csharp + [Fact] + public async Task HeatmapChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 320 + height: 240 + background: "#ffffff" + layout: + - type: chart + chart-type: heatmap + width: 320 + height: 240 + x-labels: [Mon, Tue, Wed] + y-labels: [AM, PM] + cell-values: true + series: + - data: [1, 5, 9] + - data: [7, 3, 2] + legend: none + palette: ocean + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected heatmap to draw."); + } + + [Fact] + public async Task RadarChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 300 + height: 300 + background: "#ffffff" + layout: + - type: chart + chart-type: radar + width: 300 + height: 300 + categories: [Speed, Power, Range, Agility, Armor] + series: + - label: A + data: [4, 3, 5, 2, 4] + - label: B + data: [3, 5, 2, 4, 3] + legend: none + palette: vivid + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected radar to draw."); + } +``` + +- [ ] **Step 2: Run the smoke tests** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartRenderSmokeTests"` +Expected: PASS (all cases, including the pre-existing Phase-2/3 cases). + +- [ ] **Step 3: Commit** + +```bash +git add tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs +git commit --no-gpg-sign -m "test(skia): end-to-end smoke tests for heatmap and radar" +``` + +--- + +## Task 10: Snapshot goldens for heatmap and radar + +**Files:** +- Modify: `tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs` +- Create (generated): `tests/FlexRender.Tests/Snapshots/golden/chart_heatmap_light.png`, `chart_radar_dark.png` + +> Confirm the golden directory path before generating. If `tests/FlexRender.Tests/Snapshots/golden/` does not exist, check where the existing Phase-2/3 goldens live (e.g. `ls tests/FlexRender.Tests/Snapshots/golden/ | grep chart_bar`) and use that same directory in the `git add` of Step 5. + +- [ ] **Step 1: Add the snapshot tests** + +Append these two methods inside the `ChartSnapshotTests` class (before its closing brace): + +```csharp + [Fact] + public async Task HeatmapChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 420 + background: "#ffffff" + layout: + - type: chart + chart-type: heatmap + width: 420 + height: 300 + x-labels: [Mon, Tue, Wed, Thu, Fri] + y-labels: [Morning, Afternoon, Evening] + cell-values: true + series: + - data: [2, 8, 4, 10, 6] + - data: [5, 3, 9, 7, 1] + - data: [8, 6, 2, 4, 9] + title: Activity + legend: none + palette: ocean + """); + await AssertSnapshot("chart_heatmap_light", template, new ObjectValue()); + } + + [Fact] + public async Task RadarChart_Dark() + { + var template = Parser.Parse(""" + canvas: + width: 360 + background: "#1e1e1e" + layout: + - type: chart + chart-type: radar + width: 360 + height: 360 + categories: [Speed, Power, Range, Agility, Armor, Stealth] + series: + - label: Alpha + data: [4, 3, 5, 2, 4, 3] + - label: Bravo + data: [3, 5, 2, 4, 3, 5] + theme: dark + legend: bottom + palette: vivid + """); + await AssertSnapshot("chart_radar_dark", template, new ObjectValue()); + } +``` + +- [ ] **Step 2: Run the snapshot tests to confirm they fail (no golden yet)** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"` +Expected: FAIL for the two new tests with "Golden image not found" (earlier snapshots still pass). + +- [ ] **Step 3: Generate the goldens** + +Run: `UPDATE_SNAPSHOTS=true dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"` +Expected: PASS (goldens written). Confirm two new PNGs (`chart_heatmap_light.png`, `chart_radar_dark.png`) exist in the golden directory. + +- [ ] **Step 4: Re-run without update to confirm they match** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~ChartSnapshotTests"` +Expected: PASS (all, including the two new goldens). + +- [ ] **Step 5: Commit** + +```bash +git add tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs tests/FlexRender.Tests/Snapshots/golden/chart_heatmap_light.png tests/FlexRender.Tests/Snapshots/golden/chart_radar_dark.png +git commit --no-gpg-sign -m "test(charts): snapshot goldens for heatmap and radar" +``` + +--- + +## Task 11: Full build + full test gate + +**Files:** none (verification only) + +- [ ] **Step 1: Clean build** + +Run: `dotnet build FlexRender.slnx` +Expected: BUILD SUCCEEDED, zero warnings (TreatWarningsAsErrors). If CA1859 / unused-using / unused-param errors appear, fix them in the offending file and rebuild. Likely watch points: the `RadarPoint`/`RadarGeometry` public types (XML docs required), unused `using` in new files. + +- [ ] **Step 2: Full test suite** + +Run: `dotnet test FlexRender.slnx --framework net10.0` +Expected: PASS — all tests green (Phase-1, Phase-2, Phase-3, and the new Phase-4 tests). + +- [ ] **Step 3: Commit (only if a fix was needed in Step 1)** + +```bash +git add -A +git commit --no-gpg-sign -m "chore(charts): resolve analyzer warnings for heatmap and radar" +``` + +If no fix was needed, skip this commit. + +--- + +## Task 12: Docs — Playground schema + autocomplete + +**Files:** +- Modify: `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json` +- Modify: `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` + +- [ ] **Step 1: Extend the schema chart-type enum** + +In `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json`, find the chart `"chart-type"` enum (around line 883): + +```json + "enum": ["bar", "line", "area", "pie", "donut", "scatter", "bubble", "gauge", "progress", "sparkline"] +``` + +Replace it with: + +```json + "enum": ["bar", "line", "area", "pie", "donut", "scatter", "bubble", "gauge", "progress", "sparkline", "heatmap", "radar"] +``` + +- [ ] **Step 2: Update the chart element description** + +In the same file, the `chartElement` `"description"` (around line 874) reads: + +```json + "description": "Renders a bar, line, area, pie, or donut chart. Empty/missing series renders a 'no data' placeholder.", +``` + +Replace it with: + +```json + "description": "Renders a bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline, heatmap, or radar chart. Empty/missing series renders a 'no data' placeholder.", +``` + +- [ ] **Step 3: Add the heatmap props to the chart schema** + +In the chart `properties` block, after the `"label"` property block (the gauge caption, ends at the `}` before the `properties` block's closing `}` near line 964), add three new properties (mind the trailing comma on the preceding `"label"` block's closing brace): + +```json + "label": { + "description": "Gauge/progress only. Centered caption under the value.", + "type": "string" + }, + "x-labels": { + "description": "Heatmap only. Column (x-axis) labels, left to right.", + "type": "array", + "items": { "type": "string" } + }, + "y-labels": { + "description": "Heatmap only. Row (y-axis) labels, top to bottom (one per series/row).", + "type": "array", + "items": { "type": "string" } + }, + "cell-values": { + "description": "Heatmap only. Draw each cell's numeric value inside the cell.", + "type": "boolean" + } +``` + +- [ ] **Step 4: Update autocomplete if it enumerates chart-type values** + +Inspect `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` for any literal list of chart-type values (`bar`, `donut`, `sparkline`, etc.). If such a list exists, add `heatmap` and `radar`. If the file derives completions from the schema (no hardcoded chart-type list), no edit is needed — note that in the commit body. + +Verify with: `grep -n "sparkline" src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` (no result means schema-driven; nothing to change). + +- [ ] **Step 5: Validate the JSON parses** + +Run: `node -e "JSON.parse(require('fs').readFileSync('src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json','utf8')); console.log('ok')"` +Expected: prints `ok`. + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs +git commit --no-gpg-sign -m "docs(playground): add heatmap and radar to schema and autocomplete" +``` + +--- + +## Task 13: Docs — wiki Element-Reference + Visual-Reference + +**Files:** +- Modify: `docs/wiki/Element-Reference.md` +- Modify: `docs/wiki/Visual-Reference.md` + +- [ ] **Step 1: Update the chart intro + chart-type row in Element-Reference** + +In `docs/wiki/Element-Reference.md`, in the `## chart` section: + +1. Update the intro sentence (around line 1462). It currently lists `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, and `sparkline`. Append heatmap and radar so it reads: + +``` +A declarative chart element that renders `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, `sparkline`, `heatmap`, and `radar` charts into a fixed `width` by `height` box. +``` + +2. Update the `ChartType` property-table row (around line 1480) to: + +``` +| ChartType | `chart-type` | string | `bar` | bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline, heatmap, radar | No | Chart kind. | +``` + +3. Add three rows to the chart property table immediately after the `ValueLabel` (`label`) row: + +``` +| XLabels | `x-labels` | list | `[]` | List of strings | No | **Heatmap only.** Column (x-axis) labels; falls back to `categories`. | +| YLabels | `y-labels` | list | `[]` | List of strings | No | **Heatmap only.** Row (y-axis) labels, one per series/row. | +| ShowCellValues | `cell-values` | bool | `false` | true, false | No | **Heatmap only.** Draw each cell's numeric value inside the cell. | +``` + +4. Add a note paragraph after the existing "Scatter / bubble data" note documenting heatmap/radar data: + +``` +> **Heatmap data:** each entry in `series` is one ROW; `series[i].data` holds that row's column values. `x-labels` label the columns (or `categories` if `x-labels` is omitted) and `y-labels` label the rows. Cell color encodes the value, interpolated between the palette's first ("low") and second ("high") colors; set `cell-values: true` to print the number in each cell. + +> **Radar data:** `categories` become the spokes (one axis per category, starting at the top and advancing clockwise). Each entry in `series` is a closed polygon connecting that series' values along the spokes — filled semi-transparently and stroked. Values share one radial scale anchored at zero at the center. +``` + +- [ ] **Step 2: Add examples to Visual-Reference** + +In `docs/wiki/Visual-Reference.md`, find the chart section and append two examples after the existing sparkline example block: + +```` +### Heatmap + +```yaml +- type: chart + chart-type: heatmap + width: 420 + height: 300 + x-labels: [Mon, Tue, Wed, Thu, Fri] + y-labels: [Morning, Afternoon, Evening] + cell-values: true + series: + - data: [2, 8, 4, 10, 6] + - data: [5, 3, 9, 7, 1] + - data: [8, 6, 2, 4, 9] + legend: none + palette: ocean +``` + +### Radar + +```yaml +- type: chart + chart-type: radar + width: 360 + height: 360 + categories: [Speed, Power, Range, Agility, Armor, Stealth] + series: + - label: Alpha + data: [4, 3, 5, 2, 4, 3] + - label: Bravo + data: [3, 5, 2, 4, 3, 5] + legend: bottom + palette: vivid +``` +```` + +- [ ] **Step 3: Commit** + +```bash +git add docs/wiki/Element-Reference.md docs/wiki/Visual-Reference.md +git commit --no-gpg-sign -m "docs(wiki): document heatmap and radar chart types" +``` + +--- + +## Task 14: Docs — llms.txt + llms-full.txt + +**Files:** +- Modify: `llms.txt` +- Modify: `llms-full.txt` + +- [ ] **Step 1: Update the chart-type enumeration in both files** + +In both `llms.txt` and `llms-full.txt`, find each text that enumerates chart-types (search for `sparkline`) and update the list to append `heatmap, radar`. For example, the `llms.txt` chart row lists `bar/line/area/pie/donut/scatter/bubble/gauge/progress/sparkline` — change it to `bar/line/area/pie/donut/scatter/bubble/gauge/progress/sparkline/heatmap/radar`. In `llms-full.txt`, update the `chart-type | string | bar | Chart kind: ...` table row and the intro sentence the same way. + +- [ ] **Step 2: Add a Phase-4 usage note in llms-full.txt** + +In `llms-full.txt`, in the chart section, add this note after the existing scatter/bubble/gauge/progress/sparkline description: + +``` +Heatmap takes 2D data: each `series` entry is one ROW (`series[i].data` = that row's column values), `x-labels` label the columns (or `categories` if omitted), `y-labels` label the rows, and `cell-values: true` prints each cell's number. Cell color is interpolated between the palette's first ("low") and second ("high") colors. Radar uses `categories` as spokes (one axis per category, starting at the top); each `series` is a closed polygon along the spokes, filled semi-transparently and stroked, sharing one radial scale anchored at zero at the center. +``` + +- [ ] **Step 3: Verify the strings landed** + +Run: `grep -c "heatmap" llms.txt llms-full.txt` +Expected: a non-zero count for each file. + +Run: `grep -c "radar" llms.txt llms-full.txt` +Expected: a non-zero count for each file. + +- [ ] **Step 4: Commit** + +```bash +git add llms.txt llms-full.txt +git commit --no-gpg-sign -m "docs(llms): document heatmap and radar chart types" +``` + +--- + +## Task 15: Final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Clean build** + +Run: `dotnet build FlexRender.slnx` +Expected: BUILD SUCCEEDED, zero warnings. + +- [ ] **Step 2: Full test suite (authoritative framework)** + +Run: `dotnet test FlexRender.slnx --framework net10.0` +Expected: PASS — every test green. + +- [ ] **Step 3: Confirm git state is clean** + +Run: `git status` +Expected: working tree clean (all Phase-4 work committed). The branch remains `feature/charts-and-shapes`; do NOT merge to `main`. + +--- + +## Self-review + +**Spec coverage (each Phase-4 type → task):** +- `heatmap` — type enum (Task 1); `XLabels`/`YLabels`/`ShowCellValues` props (Task 2); `x-labels`/`y-labels`/`cell-values` parsing + KnownProperties + chart-type error message (Task 3); empty → no-data placeholder (Task 4); two-color value→color scale (Task 5); grid + labels + optional cell values rendering with each series as a row (Task 6); smoke (Task 9); snapshot (Task 10). 2D data is modeled as series-as-rows (no new data structure), Categories/XLabels as columns, YLabels as rows — matching the spec's "2D `data`, `x-labels`, `y-labels`" and the recommended representation. +- `radar` — type enum (Task 1); reuses `Series` (polygons) + `Categories` (spokes), no new data structure (Task 3 test confirms); empty → no-data placeholder (Task 4); spoke geometry helper (Task 7); concentric rings + spokes + category labels + per-series closed filled+stroked polygon with radial AxisScale mapping (Task 8); smoke (Task 9); snapshot (Task 10). +- Error handling: unknown chart-type lists `heatmap`/`radar` (Task 3); unknown-prop typo suggestions covered by `KnownProperties` validation with the three new props registered (Task 3, typo test included); non-numeric/non-finite data rejected by the existing per-series flat-data validation in `ParseOneSeries` (unchanged — heatmap/radar use flat `Data`); empty data → no-data placeholder (Task 4). Degenerate geometry (radar < 3 categories, all-zero values; heatmap min == max) handled in Tasks 5/8 with explicit tests. +- Resource limits reused, not weakened: heatmap rows are series (bounded by `MaxSeriesPerChart`), columns are data points per row (bounded by `MaxDataPointsPerSeries`) via the existing checks in `ParseSeries`/`ParseOneSeries`; no new limit and no limit weakened. +- Docs: schema + autocomplete (Task 12), wiki Element-Reference + Visual-Reference (Task 13), llms.txt + llms-full.txt (Task 14). + +**Placeholder scan:** No `TBD`/`TODO`/"add appropriate"/"similar to Task N" — every code step shows complete, compilable code. The two conditional notes (Task 3 KnownProperties order tolerance; Task 4 no-change-expected guard; Task 10 golden-directory confirmation; Task 12 schema-driven autocomplete) are fully specified with exact conditions and concrete fallback actions. + +**Type consistency:** +- `ChartType.Heatmap` / `ChartType.Radar` — added Task 1, referenced in `DrawSeries` arms (Tasks 6, 8) and parser error message (Task 3). +- `ChartElement.XLabels` / `YLabels` (`IReadOnlyList`, default `Array.Empty()`) and `ShowCellValues` (`bool`) — defined Task 2, set in Task 3 (from `List` returned by `ParseStringList`, assignable to `IReadOnlyList`; `GetBoolValue` returns `bool`), consumed in Task 6 (`chart.XLabels`, `chart.YLabels`, `chart.ShowCellValues`). +- `HeatmapColorScale.Map(double value, double min, double max, SKColor low, SKColor high) → SKColor` — defined Task 5, called identically in Task 6. +- `RadarGeometry.SpokeAngle(int, int) → float` and `RadarGeometry.Project(float, float, float, int, int, float) → RadarPoint` with `RadarPoint(float X, float Y)` — defined Task 7, called with the same argument order in Task 8 (`DrawRadar`, `DrawRadarGrid`, `DrawRadarSpokeLabels`). +- `DrawHeatmap(canvas, chart, theme, width, height, typeface, antialias)` and `DrawRadar(...)` signatures match their `DrawSeries` call sites (Tasks 6, 8) and the existing draw-method convention. +- `ParseStringList(YamlMappingNode, string) → List` — defined Task 3, used for both `x-labels` and `y-labels` in the same task; `ParseCategories` left intact for `Categories`. +- `DataBounds(chart)` (existing) returns `(double Min, double Max)` — reused unchanged in Tasks 6 and 8. From 912a0b10876912a63aeae20bd2e7aa6a075a7a44 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:31:47 +0400 Subject: [PATCH 079/119] feat(charts): add heatmap and radar chart types --- src/FlexRender.Core/Charts/ChartEnums.cs | 8 ++++- .../Charts/ChartTypePhase4Tests.cs | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/FlexRender.Tests/Charts/ChartTypePhase4Tests.cs diff --git a/src/FlexRender.Core/Charts/ChartEnums.cs b/src/FlexRender.Core/Charts/ChartEnums.cs index cbb91ed..be0fba0 100644 --- a/src/FlexRender.Core/Charts/ChartEnums.cs +++ b/src/FlexRender.Core/Charts/ChartEnums.cs @@ -33,7 +33,13 @@ public enum ChartType Progress, /// Tiny inline line chart with no axes, labels, or legend. - Sparkline + Sparkline, + + /// Grid of value-colored cells (2D data: rows = series, columns = categories). + Heatmap, + + /// Radar (spider) chart: category spokes with one closed polygon per series. + Radar } /// diff --git a/tests/FlexRender.Tests/Charts/ChartTypePhase4Tests.cs b/tests/FlexRender.Tests/Charts/ChartTypePhase4Tests.cs new file mode 100644 index 0000000..cdfab69 --- /dev/null +++ b/tests/FlexRender.Tests/Charts/ChartTypePhase4Tests.cs @@ -0,0 +1,29 @@ +using System; +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Sanity tests for the Phase-4 chart type enum members. +/// +public sealed class ChartTypePhase4Tests +{ + [Theory] + [InlineData("Heatmap")] + [InlineData("Radar")] + public void ChartType_HasAllPhase4Members(string name) + { + Assert.True(Enum.TryParse(name, out _)); + } + + [Theory] + [InlineData("Bar")] + [InlineData("Scatter")] + [InlineData("Gauge")] + [InlineData("Sparkline")] + public void ChartType_StillHasEarlierMembers(string name) + { + Assert.True(Enum.TryParse(name, out _)); + } +} From d21c32cf637f45859d416a7148a5bd778d062a3d Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:32:51 +0400 Subject: [PATCH 080/119] feat(ast): add XLabels/YLabels/ShowCellValues to ChartElement for heatmap --- .../Parsing/Ast/ChartElement.cs | 14 ++++++- .../Parsing/Ast/ChartElementHeatmapTests.cs | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/FlexRender.Tests/Parsing/Ast/ChartElementHeatmapTests.cs diff --git a/src/FlexRender.Core/Parsing/Ast/ChartElement.cs b/src/FlexRender.Core/Parsing/Ast/ChartElement.cs index 930cbc1..faef8ad 100644 --- a/src/FlexRender.Core/Parsing/Ast/ChartElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/ChartElement.cs @@ -74,6 +74,15 @@ public ChartElement(ChartType chartType, IReadOnlyList series) /// Gets or sets the centered caption for gauge/progress charts (distinct from ). public string? ValueLabel { get; set; } + /// Gets or sets the heatmap column (x-axis) labels. Empty falls back to . + public IReadOnlyList XLabels { get; set; } = Array.Empty(); + + /// Gets or sets the heatmap row (y-axis) labels, one per series/row. + public IReadOnlyList YLabels { get; set; } = Array.Empty(); + + /// Gets or sets whether the heatmap draws each cell's numeric value inside the cell. + public bool ShowCellValues { get; set; } + /// public override TemplateElement CloneWithSubstitution(Func substitutor) { @@ -93,7 +102,10 @@ public override TemplateElement CloneWithSubstitution(Func sub PieLabels = PieLabels, Value = Value, Max = Max, - ValueLabel = ValueLabel + ValueLabel = ValueLabel, + XLabels = XLabels, + YLabels = YLabels, + ShowCellValues = ShowCellValues }; CopyBasePropertiesTo(clone, substitutor); return clone; diff --git a/tests/FlexRender.Tests/Parsing/Ast/ChartElementHeatmapTests.cs b/tests/FlexRender.Tests/Parsing/Ast/ChartElementHeatmapTests.cs new file mode 100644 index 0000000..b030418 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Ast/ChartElementHeatmapTests.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing.Ast; + +/// +/// Tests for the heatmap label/cell-value properties on . +/// +public sealed class ChartElementHeatmapTests +{ + [Fact] + public void Defaults_LabelsEmpty_CellValuesFalse() + { + var chart = new ChartElement(ChartType.Heatmap, new List()); + Assert.Empty(chart.XLabels); + Assert.Empty(chart.YLabels); + Assert.False(chart.ShowCellValues); + } + + [Fact] + public void CloneWithSubstitution_PreservesLabelsAndCellValues() + { + var chart = new ChartElement(ChartType.Heatmap, new List()) + { + XLabels = new[] { "Mon", "Tue" }, + YLabels = new[] { "AM", "PM" }, + ShowCellValues = true + }; + + var clone = (ChartElement)chart.CloneWithSubstitution(s => s); + + Assert.Equal(new[] { "Mon", "Tue" }, clone.XLabels); + Assert.Equal(new[] { "AM", "PM" }, clone.YLabels); + Assert.True(clone.ShowCellValues); + } +} From 2c6fc09bc0755d44a7f00283d38e79ea3b177820 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:34:07 +0400 Subject: [PATCH 081/119] feat(yaml): parse heatmap x-labels/y-labels/cell-values and register chart props --- src/FlexRender.Yaml/Parsing/ChartParsers.cs | 25 +++- .../Parsing/KnownProperties.cs | 1 + .../Parsing/ChartHeatmapParsersTests.cs | 135 ++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 tests/FlexRender.Tests/Parsing/ChartHeatmapParsersTests.cs diff --git a/src/FlexRender.Yaml/Parsing/ChartParsers.cs b/src/FlexRender.Yaml/Parsing/ChartParsers.cs index 0c4c711..1352ff9 100644 --- a/src/FlexRender.Yaml/Parsing/ChartParsers.cs +++ b/src/FlexRender.Yaml/Parsing/ChartParsers.cs @@ -43,6 +43,9 @@ internal static TemplateElement ParseChartElement(YamlMappingNode node, int maxS Value = GetDoubleValue(node, "value"), Max = GetDoubleValue(node, "max"), ValueLabel = GetStringValue(node, "label"), + XLabels = ParseStringList(node, "x-labels"), + YLabels = ParseStringList(node, "y-labels"), + ShowCellValues = GetBoolValue(node, "cell-values", false), Background = GetStringValue(node, "background")!, Rotate = GetExprStringValue(node, "rotate", "none"), Padding = GetExprStringValue(node, "padding", "0"), @@ -65,7 +68,7 @@ private static ChartType ParseChartType(YamlMappingNode node) if (!Enum.TryParse(raw, ignoreCase: true, out var chartType)) { throw new TemplateParseException( - $"Unknown chart-type '{raw}'. Valid values: bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline."); + $"Unknown chart-type '{raw}'. Valid values: bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline, heatmap, radar."); } return chartType; } @@ -123,6 +126,26 @@ private static List ParseCategories(YamlMappingNode node) return categories; } + /// + /// Parses an optional sequence of scalar string labels under the given key (heatmap x/y labels). + /// + /// The chart mapping node. + /// The property key (e.g. "x-labels"). + /// The labels in order; empty when the key is absent. + private static List ParseStringList(YamlMappingNode node, string key) + { + var labels = new List(); + if (TryGetSequence(node, key, out var seq)) + { + foreach (var item in seq.Children) + { + if (item is YamlScalarNode scalar && scalar.Value is not null) + labels.Add(scalar.Value); + } + } + return labels; + } + /// /// Parses the optional palette property in either named or explicit-color-list form. /// diff --git a/src/FlexRender.Yaml/Parsing/KnownProperties.cs b/src/FlexRender.Yaml/Parsing/KnownProperties.cs index 70560eb..5d1bc41 100644 --- a/src/FlexRender.Yaml/Parsing/KnownProperties.cs +++ b/src/FlexRender.Yaml/Parsing/KnownProperties.cs @@ -201,6 +201,7 @@ internal static class KnownProperties "chart-type", "categories", "series", "palette", "theme", "legend", "title", "horizontal", "stacked", "smooth", "points", "labels", "value", "max", "label", + "x-labels", "y-labels", "cell-values", "background", "rotate", "padding", "margin" ]); diff --git a/tests/FlexRender.Tests/Parsing/ChartHeatmapParsersTests.cs b/tests/FlexRender.Tests/Parsing/ChartHeatmapParsersTests.cs new file mode 100644 index 0000000..d3a0452 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/ChartHeatmapParsersTests.cs @@ -0,0 +1,135 @@ +using FlexRender.Charts; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// +/// Tests for parsing heatmap labels / cell-values, radar series, and the updated chart-type validation. +/// +public sealed class ChartHeatmapParsersTests +{ + private readonly TemplateParser _parser = new(); + + [Fact] + public void Parse_Heatmap_ReadsLabelsAndCellValues() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: heatmap + width: 400 + height: 300 + x-labels: [Mon, Tue, Wed] + y-labels: [AM, PM] + cell-values: true + series: + - data: [1, 2, 3] + - data: [4, 5, 6] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal(ChartType.Heatmap, chart.ChartType); + Assert.Equal(new[] { "Mon", "Tue", "Wed" }, chart.XLabels); + Assert.Equal(new[] { "AM", "PM" }, chart.YLabels); + Assert.True(chart.ShowCellValues); + Assert.Equal(2, chart.Series.Count); + Assert.Equal(3, chart.Series[0].Data.Count); + } + + [Fact] + public void Parse_Heatmap_DefaultsLabelsEmptyCellValuesFalse() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: heatmap + width: 400 + height: 300 + series: + - data: [1, 2] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Empty(chart.XLabels); + Assert.Empty(chart.YLabels); + Assert.False(chart.ShowCellValues); + } + + [Fact] + public void Parse_Radar_ReadsCategoriesAndSeries() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: radar + width: 400 + height: 400 + categories: [Speed, Power, Range, Agility, Armor] + series: + - label: A + data: [4, 3, 5, 2, 4] + - label: B + data: [3, 5, 2, 4, 3] + """; + + var template = _parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + Assert.Equal(ChartType.Radar, chart.ChartType); + Assert.Equal(5, chart.Categories.Count); + Assert.Equal(2, chart.Series.Count); + Assert.Equal(5, chart.Series[0].Data.Count); + } + + [Fact] + public void Parse_TypoInXLabels_Throws() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: heatmap + width: 400 + height: 300 + x-labesl: [Mon, Tue] + series: + - data: [1, 2] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("x-labesl", ex.Message); + } + + [Fact] + public void Parse_UnknownChartType_ErrorListsHeatmapAndRadar() + { + const string yaml = """ + canvas: + width: 400 + layout: + - type: chart + chart-type: nope + width: 400 + height: 300 + series: + - data: [1, 2] + """; + + var ex = Assert.Throws(() => _parser.Parse(yaml)); + Assert.Contains("heatmap", ex.Message); + Assert.Contains("radar", ex.Message); + } +} From 4bc9adc7932c528a9b2b40bcc0ef5c434fadabb7 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:34:49 +0400 Subject: [PATCH 082/119] test(skia): no-data placeholder for empty heatmap and radar --- .../Rendering/ChartHeatmapRadarDataTests.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/FlexRender.Tests/Rendering/ChartHeatmapRadarDataTests.cs diff --git a/tests/FlexRender.Tests/Rendering/ChartHeatmapRadarDataTests.cs b/tests/FlexRender.Tests/Rendering/ChartHeatmapRadarDataTests.cs new file mode 100644 index 0000000..19c0cf7 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartHeatmapRadarDataTests.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies empty heatmap/radar charts draw the no-data placeholder and non-empty ones draw content. +/// +public sealed class ChartHeatmapRadarDataTests +{ + [Fact] + public void EmptyHeatmap_DrawsNoDataPlaceholder() + { + var chart = new ChartElement(ChartType.Heatmap, new List()) + { + Legend = LegendPosition.None, + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(200, 150, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 150f, typeface: null, antialias: true); + + Assert.True(HasNonWhitePixel(bitmap), "Expected the no-data border to draw."); + } + + [Fact] + public void EmptyRadar_DrawsNoDataPlaceholder() + { + var chart = new ChartElement(ChartType.Radar, new List()) + { + Legend = LegendPosition.None, + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: true); + + Assert.True(HasNonWhitePixel(bitmap), "Expected the no-data border to draw."); + } + + private static bool HasNonWhitePixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red < 240 || p.Green < 240 || p.Blue < 240) + return true; + } + return false; + } +} From d6aefce0662a043971c0af27e63ee834fabbacc8 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:37:23 +0400 Subject: [PATCH 083/119] feat(skia): add heatmap two-color value-to-color interpolation --- .../Rendering/HeatmapColorScale.cs | 47 +++++++++++++++ .../Rendering/HeatmapColorScaleTests.cs | 57 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/FlexRender.Skia.Render/Rendering/HeatmapColorScale.cs create mode 100644 tests/FlexRender.Tests/Rendering/HeatmapColorScaleTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/HeatmapColorScale.cs b/src/FlexRender.Skia.Render/Rendering/HeatmapColorScale.cs new file mode 100644 index 0000000..3b76dfb --- /dev/null +++ b/src/FlexRender.Skia.Render/Rendering/HeatmapColorScale.cs @@ -0,0 +1,47 @@ +using System; +using SkiaSharp; + +namespace FlexRender.Rendering; + +/// +/// Maps a numeric value within a data range to a color interpolated channel-by-channel between a +/// "low" and a "high" color. Pure and deterministic (AOT-safe, no allocations, no randomness), +/// suitable for snapshot-stable heatmap cell coloring. +/// +internal static class HeatmapColorScale +{ + /// + /// Maps in .. to a color + /// linearly interpolated between (at ) and + /// (at ). The interpolation parameter is clamped + /// to [0, 1]; a degenerate range ( == ) + /// yields . + /// + /// The cell value. + /// The data minimum (maps to ). + /// The data maximum (maps to ). + /// The color at the low end of the range. + /// The color at the high end of the range. + /// The interpolated, fully opaque color. + public static SKColor Map(double value, double min, double max, SKColor low, SKColor high) + { + var span = max - min; + var t = span <= 0d ? 1d : Math.Clamp((value - min) / span, 0d, 1d); + + var r = Lerp(low.Red, high.Red, t); + var g = Lerp(low.Green, high.Green, t); + var b = Lerp(low.Blue, high.Blue, t); + return new SKColor(r, g, b); + } + + /// Linearly interpolates a single 0..255 channel and rounds to the nearest byte. + /// The channel value at t = 0. + /// The channel value at t = 1. + /// The interpolation parameter in [0, 1]. + /// The interpolated channel as a byte. + private static byte Lerp(byte a, byte b, double t) + { + var v = a + ((b - a) * t); + return (byte)Math.Clamp(Math.Round(v), 0d, 255d); + } +} diff --git a/tests/FlexRender.Tests/Rendering/HeatmapColorScaleTests.cs b/tests/FlexRender.Tests/Rendering/HeatmapColorScaleTests.cs new file mode 100644 index 0000000..6844f02 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/HeatmapColorScaleTests.cs @@ -0,0 +1,57 @@ +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Tests for the heatmap two-color value-to-color interpolation. +/// +public sealed class HeatmapColorScaleTests +{ + private static readonly SKColor Low = new(0, 0, 0); // black + private static readonly SKColor High = new(200, 100, 50); // warm + + [Fact] + public void Map_AtMin_ReturnsLowColor() + { + var c = HeatmapColorScale.Map(0d, 0d, 10d, Low, High); + Assert.Equal(Low.Red, c.Red); + Assert.Equal(Low.Green, c.Green); + Assert.Equal(Low.Blue, c.Blue); + } + + [Fact] + public void Map_AtMax_ReturnsHighColor() + { + var c = HeatmapColorScale.Map(10d, 0d, 10d, Low, High); + Assert.Equal(High.Red, c.Red); + Assert.Equal(High.Green, c.Green); + Assert.Equal(High.Blue, c.Blue); + } + + [Fact] + public void Map_Midpoint_IsBetweenLowAndHigh() + { + var c = HeatmapColorScale.Map(5d, 0d, 10d, Low, High); + Assert.Equal((byte)100, c.Red); // (0 + 200) / 2 + Assert.Equal((byte)50, c.Green); // (0 + 100) / 2 + Assert.Equal((byte)25, c.Blue); // (0 + 50) / 2 + } + + [Fact] + public void Map_DegenerateRange_ReturnsHighColor() + { + var c = HeatmapColorScale.Map(5d, 5d, 5d, Low, High); + Assert.Equal(High.Red, c.Red); + Assert.Equal(High.Green, c.Green); + Assert.Equal(High.Blue, c.Blue); + } + + [Fact] + public void Map_BelowMin_ClampsToLow() + { + var c = HeatmapColorScale.Map(-5d, 0d, 10d, Low, High); + Assert.Equal(Low.Red, c.Red); + } +} From 39a4261443b5231f8669f9945bedc552ec48792c Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:38:36 +0400 Subject: [PATCH 084/119] feat(skia): render heatmap charts --- .../Rendering/ChartRenderer.cs | 118 ++++++++++++++++++ .../Rendering/ChartHeatmapRenderTests.cs | 51 ++++++++ 2 files changed, 169 insertions(+) create mode 100644 tests/FlexRender.Tests/Rendering/ChartHeatmapRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 417cacb..d7d9c68 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -177,6 +177,9 @@ private static void DrawSeries( case ChartType.Progress: DrawProgress(canvas, chart, theme, width, height, typeface, antialias); break; + case ChartType.Heatmap: + DrawHeatmap(canvas, chart, theme, width, height, typeface, antialias); + break; default: // Other chart types are added in later tasks. using (var border = new SKPaint @@ -875,6 +878,121 @@ private static void DrawProgress( DrawIndicatorText(canvas, chart, theme, value, max, cx, cy, typeface, antialias); } + /// + /// Draws a heatmap: each series is one row and each column is a category. Cell color encodes the + /// value, interpolated between the palette's first ("low") and second ("high") colors. Optional + /// x/y labels and per-cell value text are drawn when a typeface is available. + /// + private static void DrawHeatmap( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + var rowCount = chart.Series.Count; + if (rowCount == 0) + return; + + // Columns = the widest row; ragged rows simply leave trailing cells unpainted. + var colCount = 0; + foreach (var s in chart.Series) + colCount = Math.Max(colCount, s.Data.Count); + if (colCount == 0) + return; + + // Global value range across every cell, for the color scale. + var (minV, maxV) = DataBounds(chart); + + var palette = chart.Palette ?? ChartPalettes.Default; + var lowColor = ColorParser.Parse(palette.ColorAt(0)); + var highColor = ColorParser.Parse(palette.Colors.Count > 1 ? palette.ColorAt(1) : palette.ColorAt(0)); + + // X labels: prefer XLabels, fall back to Categories. + var xLabels = chart.XLabels.Count > 0 ? chart.XLabels : chart.Categories; + var yLabels = chart.YLabels; + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var top = hasTitle ? theme.TitleSize + 8f : 4f; + // Reserve a left gutter for y-labels and a bottom gutter for x-labels (only when labelled). + var leftGutter = typeface is not null && yLabels.Count > 0 ? 52f : 6f; + var bottomGutter = typeface is not null && xLabels.Count > 0 ? theme.LabelSize + 8f : 6f; + + var gridLeft = leftGutter; + var gridTop = top; + var gridRight = width - 6f; + var gridBottom = height - bottomGutter; + if (gridRight <= gridLeft || gridBottom <= gridTop) + return; + + var cellW = (gridRight - gridLeft) / colCount; + var cellH = (gridBottom - gridTop) / rowCount; + + SKFont? font = null; + SKPaint? labelPaint = null; + if (typeface is not null) + { + font = new SKFont(typeface, theme.LabelSize); + labelPaint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + } + + try + { + using var cellPaint = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = antialias }; + using var cellValuePaint = new SKPaint { IsAntialias = antialias }; + + for (var ri = 0; ri < rowCount; ri++) + { + var data = chart.Series[ri].Data; + var cellTop = gridTop + (cellH * ri); + + for (var ci = 0; ci < data.Count; ci++) + { + var value = data[ci]; + var cellLeft = gridLeft + (cellW * ci); + cellPaint.Color = HeatmapColorScale.Map(value, minV, maxV, lowColor, highColor); + // Inset by half a pixel to leave a faint grid seam between cells. + canvas.DrawRect(cellLeft + 0.5f, cellTop + 0.5f, cellW - 1f, cellH - 1f, cellPaint); + + if (chart.ShowCellValues && font is not null) + { + // Choose dark or light value text for contrast against the cell color. + var cell = cellPaint.Color; + var luminance = (0.299f * cell.Red) + (0.587f * cell.Green) + (0.114f * cell.Blue); + cellValuePaint.Color = luminance > 140f ? SKColors.Black : SKColors.White; + + var text = FormatTick(value); + var tw = font.MeasureText(text); + var tx = cellLeft + ((cellW - tw) / 2f); + var ty = cellTop + ((cellH + theme.LabelSize) / 2f) - 2f; + canvas.DrawText(text, tx, ty, SKTextAlign.Left, font, cellValuePaint); + } + } + } + + if (font is not null && labelPaint is not null) + { + for (var ci = 0; ci < xLabels.Count && ci < colCount; ci++) + { + var label = xLabels[ci]; + var tw = font.MeasureText(label); + var cx = gridLeft + (cellW * (ci + 0.5f)); + canvas.DrawText(label, cx - (tw / 2f), gridBottom + theme.LabelSize + 2f, SKTextAlign.Left, font, labelPaint); + } + + for (var ri = 0; ri < yLabels.Count && ri < rowCount; ri++) + { + var label = yLabels[ri]; + var tw = font.MeasureText(label); + var cy = gridTop + (cellH * (ri + 0.5f)) + (theme.LabelSize / 3f); + canvas.DrawText(label, gridLeft - tw - 6f, cy, SKTextAlign.Left, font, labelPaint); + } + } + } + finally + { + font?.Dispose(); + labelPaint?.Dispose(); + } + } + /// /// Builds an open path through the given series points. When is true, /// adjacent points are joined with cubic Bézier segments derived from Catmull-Rom tangents, diff --git a/tests/FlexRender.Tests/Rendering/ChartHeatmapRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartHeatmapRenderTests.cs new file mode 100644 index 0000000..096b45d --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartHeatmapRenderTests.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies the heatmap draws value-colored cells. +/// +public sealed class ChartHeatmapRenderTests +{ + [Fact] + public void Heatmap_DrawsHighValueCellInHighColor() + { + // Two rows of two cells; the max value (40) cell must paint the palette "high" color (red). + var chart = new ChartElement(ChartType.Heatmap, new List + { + ChartSeries.FromInline("r0", new[] { 0d, 10d }), + ChartSeries.FromInline("r1", new[] { 20d, 40d }) + }) + { + Legend = LegendPosition.None, + // low = white, high = red, so the highest cell is red and the lowest is white. + Palette = new ChartPalette(new[] { "#ffffff", "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: false); + + Assert.True(HasRedPixel(bitmap), "Expected the highest-value cell to be red."); + } + + private static bool HasRedPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + if (p.Red > 180 && p.Green < 100 && p.Blue < 100) + return true; + } + return false; + } +} From ddcf77a100d3dc19fc4320326ae83303c78f5e4a Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:41:04 +0400 Subject: [PATCH 085/119] feat(charts): add radar spoke geometry helper --- src/FlexRender.Core/Charts/RadarGeometry.cs | 54 +++++++++++++++++++ .../Charts/RadarGeometryTests.cs | 43 +++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/FlexRender.Core/Charts/RadarGeometry.cs create mode 100644 tests/FlexRender.Tests/Charts/RadarGeometryTests.cs diff --git a/src/FlexRender.Core/Charts/RadarGeometry.cs b/src/FlexRender.Core/Charts/RadarGeometry.cs new file mode 100644 index 0000000..7264d4e --- /dev/null +++ b/src/FlexRender.Core/Charts/RadarGeometry.cs @@ -0,0 +1,54 @@ +using System; + +namespace FlexRender.Charts; + +/// +/// A 2D point in pixel space, returned by so the Core math layer stays +/// independent of any rendering library. +/// +/// The pixel X. +/// The pixel Y. +public readonly record struct RadarPoint(float X, float Y); + +/// +/// Pure, renderer-agnostic geometry for radar (spider) charts. Spoke 0 points straight up and +/// successive spokes advance clockwise; a value's radial fraction (0 at center, 1 at the outer +/// radius) projects onto its spoke. +/// +public static class RadarGeometry +{ + /// + /// Returns the angle (in radians) of a spoke, measured from the positive X axis with screen Y + /// growing downward. Spoke 0 is at -PI/2 (straight up); spokes advance clockwise. + /// + /// The zero-based spoke index. + /// The total number of spokes (must be positive). + /// The spoke angle in radians. + public static float SpokeAngle(int spokeIndex, int spokeCount) + { + if (spokeCount <= 0) + return -MathF.PI / 2f; + return (-MathF.PI / 2f) + ((2f * MathF.PI) * spokeIndex / spokeCount); + } + + /// + /// Projects a radial fraction onto a spoke, returning the pixel point. + /// + /// The radar center X in pixels. + /// The radar center Y in pixels. + /// The outer radius in pixels (fraction 1 maps here). + /// The zero-based spoke index. + /// The total number of spokes. + /// The radial fraction in [0, 1] (clamped). + /// The projected pixel point. + public static RadarPoint Project( + float centerX, float centerY, float radius, int spokeIndex, int spokeCount, float fraction) + { + var f = Math.Clamp(fraction, 0f, 1f); + var angle = SpokeAngle(spokeIndex, spokeCount); + var r = radius * f; + return new RadarPoint( + centerX + (r * MathF.Cos(angle)), + centerY + (r * MathF.Sin(angle))); + } +} diff --git a/tests/FlexRender.Tests/Charts/RadarGeometryTests.cs b/tests/FlexRender.Tests/Charts/RadarGeometryTests.cs new file mode 100644 index 0000000..d51c32d --- /dev/null +++ b/tests/FlexRender.Tests/Charts/RadarGeometryTests.cs @@ -0,0 +1,43 @@ +using System; +using FlexRender.Charts; +using Xunit; + +namespace FlexRender.Tests.Charts; + +/// +/// Tests for radar spoke geometry: angles start at the top and advance clockwise. +/// +public sealed class RadarGeometryTests +{ + [Fact] + public void SpokeAngle_FirstSpoke_PointsUp() + { + // Spoke 0 of 4 is straight up: -90 degrees == -PI/2 radians. + var angle = RadarGeometry.SpokeAngle(0, 4); + Assert.Equal(-MathF.PI / 2f, angle, 4); + } + + [Fact] + public void SpokeAngle_SecondOfFour_PointsRight() + { + // Spoke 1 of 4 is to the right: 0 radians. + var angle = RadarGeometry.SpokeAngle(1, 4); + Assert.Equal(0f, angle, 4); + } + + [Fact] + public void Project_ZeroFraction_ReturnsCenter() + { + var p = RadarGeometry.Project(centerX: 100f, centerY: 100f, radius: 50f, spokeIndex: 0, spokeCount: 4, fraction: 0f); + Assert.Equal(100f, p.X, 3); + Assert.Equal(100f, p.Y, 3); + } + + [Fact] + public void Project_FullFractionFirstSpoke_ReachesTopEdge() + { + var p = RadarGeometry.Project(centerX: 100f, centerY: 100f, radius: 50f, spokeIndex: 0, spokeCount: 4, fraction: 1f); + Assert.Equal(100f, p.X, 3); // straight up: x unchanged + Assert.Equal(50f, p.Y, 3); // y = 100 - 50 + } +} From d54bf03cbaef0bcd3ea4d8d84a556cac54587be0 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:42:25 +0400 Subject: [PATCH 086/119] feat(skia): render radar charts --- .../Rendering/ChartRenderer.cs | 147 ++++++++++++++++++ .../Rendering/ChartRadarRenderTests.cs | 72 +++++++++ 2 files changed, 219 insertions(+) create mode 100644 tests/FlexRender.Tests/Rendering/ChartRadarRenderTests.cs diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index d7d9c68..ee4c85b 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -180,6 +180,9 @@ private static void DrawSeries( case ChartType.Heatmap: DrawHeatmap(canvas, chart, theme, width, height, typeface, antialias); break; + case ChartType.Radar: + DrawRadar(canvas, chart, theme, width, height, typeface, antialias); + break; default: // Other chart types are added in later tasks. using (var border = new SKPaint @@ -993,6 +996,150 @@ private static void DrawHeatmap( } } + /// + /// Draws a radar (spider) chart: N spokes for N categories, concentric grid rings, and one + /// closed polygon per series filled semi-transparently and stroked. Values map radially from + /// the center (axis minimum, anchored at zero) to the outer radius (axis maximum). + /// + private static void DrawRadar( + SKCanvas canvas, ChartElement chart, ChartTheme theme, + float width, float height, SKTypeface? typeface, bool antialias) + { + var spokeCount = chart.Categories.Count; + // Without categories, fall back to the longest series length so a polygon can still draw. + if (spokeCount == 0) + { + foreach (var s in chart.Series) + spokeCount = Math.Max(spokeCount, s.Data.Count); + } + if (spokeCount == 0) + return; + + var (dataMin, dataMax) = DataBounds(chart); + var scale = AxisScale.Compute(dataMin, dataMax, targetTicks: 4); + var span = scale.Max - scale.Min; + + var hasTitle = !string.IsNullOrEmpty(chart.Title); + var legendReserve = chart.Legend is LegendPosition.Bottom ? 28f : 0f; + var top = hasTitle ? theme.TitleSize + 8f : 8f; + var availH = height - top - legendReserve - 8f; + var availW = width - 16f; + if (availH <= 0f || availW <= 0f) + return; + + // Leave room for category labels around the rim. + var labelRoom = typeface is not null && spokeCount > 0 ? theme.LabelSize + 6f : 0f; + var radius = (MathF.Min(availW, availH) / 2f) - labelRoom; + if (radius <= 0f) + return; + + var cx = width / 2f; + var cy = top + (availH / 2f); + + DrawRadarGrid(canvas, theme, cx, cy, radius, spokeCount, antialias); + DrawRadarSpokeLabels(canvas, chart, theme, cx, cy, radius, spokeCount, typeface, antialias); + + var palette = chart.Palette ?? ChartPalettes.Default; + + for (var si = 0; si < chart.Series.Count; si++) + { + var data = chart.Series[si].Data; + if (data.Count == 0) + continue; + + var color = ColorParser.Parse(palette.ColorAt(si)); + + using var path = new SKPath(); + for (var i = 0; i < spokeCount; i++) + { + var value = i < data.Count ? data[i] : scale.Min; + var fraction = span <= 0d ? 0f : (float)((value - scale.Min) / span); + var p = RadarGeometry.Project(cx, cy, radius, i, spokeCount, fraction); + if (i == 0) + path.MoveTo(p.X, p.Y); + else + path.LineTo(p.X, p.Y); + } + path.Close(); + + using var fillPaint = new SKPaint + { + Color = color.WithAlpha(70), + Style = SKPaintStyle.Fill, + IsAntialias = antialias + }; + canvas.DrawPath(path, fillPaint); + + using var strokePaint = new SKPaint + { + Color = color, + Style = SKPaintStyle.Stroke, + StrokeWidth = theme.LineWidth, + IsAntialias = antialias, + StrokeJoin = SKStrokeJoin.Round + }; + canvas.DrawPath(path, strokePaint); + } + } + + /// Draws the radar's concentric grid rings and radial spoke lines. + private static void DrawRadarGrid( + SKCanvas canvas, ChartTheme theme, float cx, float cy, float radius, int spokeCount, bool antialias) + { + using var gridPaint = new SKPaint + { + Color = ColorParser.Parse(theme.GridColor), + Style = SKPaintStyle.Stroke, + StrokeWidth = 1f, + IsAntialias = antialias + }; + + const int rings = 4; + for (var ring = 1; ring <= rings; ring++) + { + var r = radius * ring / rings; + using var ringPath = new SKPath(); + for (var i = 0; i < spokeCount; i++) + { + var p = RadarGeometry.Project(cx, cy, r, i, spokeCount, 1f); + if (i == 0) + ringPath.MoveTo(p.X, p.Y); + else + ringPath.LineTo(p.X, p.Y); + } + ringPath.Close(); + canvas.DrawPath(ringPath, gridPaint); + } + + for (var i = 0; i < spokeCount; i++) + { + var p = RadarGeometry.Project(cx, cy, radius, i, spokeCount, 1f); + canvas.DrawLine(cx, cy, p.X, p.Y, gridPaint); + } + } + + /// Draws each category label just outside its spoke's outer end, when a typeface is available. + private static void DrawRadarSpokeLabels( + SKCanvas canvas, ChartElement chart, ChartTheme theme, float cx, float cy, float radius, + int spokeCount, SKTypeface? typeface, bool antialias) + { + if (typeface is null || chart.Categories.Count == 0) + return; + + using var font = new SKFont(typeface, theme.LabelSize); + using var paint = new SKPaint { Color = ColorParser.Parse(theme.LabelColor), IsAntialias = antialias }; + + for (var i = 0; i < spokeCount && i < chart.Categories.Count; i++) + { + var label = chart.Categories[i]; + // Project slightly past the rim so the text sits outside the outer ring. + var p = RadarGeometry.Project(cx, cy, radius + (theme.LabelSize * 0.6f), i, spokeCount, 1f); + var tw = font.MeasureText(label); + // Center the text horizontally on its anchor; nudge vertically by a third of the label size. + canvas.DrawText(label, p.X - (tw / 2f), p.Y + (theme.LabelSize / 3f), SKTextAlign.Left, font, paint); + } + } + /// /// Builds an open path through the given series points. When is true, /// adjacent points are joined with cubic Bézier segments derived from Catmull-Rom tangents, diff --git a/tests/FlexRender.Tests/Rendering/ChartRadarRenderTests.cs b/tests/FlexRender.Tests/Rendering/ChartRadarRenderTests.cs new file mode 100644 index 0000000..d36b3f8 --- /dev/null +++ b/tests/FlexRender.Tests/Rendering/ChartRadarRenderTests.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Rendering; +using SkiaSharp; +using Xunit; + +namespace FlexRender.Tests.Rendering; + +/// +/// Verifies radar draws a palette-colored polygon for each series. +/// +public sealed class ChartRadarRenderTests +{ + [Fact] + public void Radar_DrawsColoredPolygon() + { + var chart = new ChartElement(ChartType.Radar, new List + { + ChartSeries.FromInline("A", new[] { 4d, 3d, 5d, 2d, 4d }) + }) + { + Categories = new[] { "Speed", "Power", "Range", "Agility", "Armor" }, + Legend = LegendPosition.None, + Palette = new ChartPalette(new[] { "#ff0000" }), + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(260, 260, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + ChartRenderer.Draw(canvas, chart, 0f, 0f, 260f, 260f, typeface: null, antialias: true); + + Assert.True(HasReddishPixel(bitmap), "Expected a red radar polygon (fill or stroke)."); + } + + [Fact] + public void Radar_FewerThanThreeCategories_DoesNotThrow() + { + var chart = new ChartElement(ChartType.Radar, new List + { + ChartSeries.FromInline("A", new[] { 4d, 3d }) + }) + { + Categories = new[] { "X", "Y" }, + Legend = LegendPosition.None, + Theme = ChartThemes.Default + }; + + using var bitmap = new SKBitmap(200, 200, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + var ex = Record.Exception(() => + ChartRenderer.Draw(canvas, chart, 0f, 0f, 200f, 200f, typeface: null, antialias: true)); + Assert.Null(ex); + } + + private static bool HasReddishPixel(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + for (var x = 0; x < bitmap.Width; x++) + { + var p = bitmap.GetPixel(x, y); + // The fill is semi-transparent red over white, so allow elevated red with lower G/B. + if (p.Red > 200 && p.Green < 200 && p.Blue < 200 && (p.Red - p.Green) > 30) + return true; + } + return false; + } +} From f65c49f1b25cd04a55c0bbde44d843bfc3253fff Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:44:11 +0400 Subject: [PATCH 087/119] test(skia): end-to-end smoke tests for heatmap and radar --- .../Rendering/ChartRenderSmokeTests.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs b/tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs index 473bb45..d7c3ba2 100644 --- a/tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs +++ b/tests/FlexRender.Tests/Rendering/ChartRenderSmokeTests.cs @@ -186,6 +186,64 @@ public async Task SparklineChart_FromYaml_DrawsPixels() Assert.True(HasNonBackgroundPixel(bitmap), "Expected sparkline to draw."); } + [Fact] + public async Task HeatmapChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 320 + height: 240 + background: "#ffffff" + layout: + - type: chart + chart-type: heatmap + width: 320 + height: 240 + x-labels: [Mon, Tue, Wed] + y-labels: [AM, PM] + cell-values: true + series: + - data: [1, 5, 9] + - data: [7, 3, 2] + legend: none + palette: ocean + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected heatmap to draw."); + } + + [Fact] + public async Task RadarChart_FromYaml_DrawsPixels() + { + const string yaml = """ + canvas: + width: 300 + height: 300 + background: "#ffffff" + layout: + - type: chart + chart-type: radar + width: 300 + height: 300 + categories: [Speed, Power, Range, Agility, Armor] + series: + - label: A + data: [4, 3, 5, 2, 4] + - label: B + data: [3, 5, 2, 4, 3] + legend: none + palette: vivid + """; + + var template = _parser.Parse(yaml); + using var bitmap = await Render(template, new ObjectValue()); + + Assert.True(HasNonBackgroundPixel(bitmap), "Expected radar to draw."); + } + private static bool HasNonBackgroundPixel(SKBitmap bitmap) { for (var y = 0; y < bitmap.Height; y += 3) From d9e771d0e10c85b248cf604b9eec198ead93d234 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:45:01 +0400 Subject: [PATCH 088/119] test(charts): snapshot goldens for heatmap and radar --- .../Snapshots/ChartSnapshotTests.cs | 51 ++++++++++++++++++ .../Snapshots/golden/chart_heatmap_light.png | Bin 0 -> 11176 bytes .../Snapshots/golden/chart_radar_dark.png | Bin 0 -> 30517 bytes 3 files changed, 51 insertions(+) create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_heatmap_light.png create mode 100644 tests/FlexRender.Tests/Snapshots/golden/chart_radar_dark.png diff --git a/tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs b/tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs index 9ce6048..ae901ef 100644 --- a/tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs +++ b/tests/FlexRender.Tests/Snapshots/ChartSnapshotTests.cs @@ -244,6 +244,57 @@ public async Task SparklineChart_Light() await AssertSnapshot("chart_sparkline_light", template, new ObjectValue()); } + [Fact] + public async Task HeatmapChart_Light() + { + var template = Parser.Parse(""" + canvas: + width: 420 + background: "#ffffff" + layout: + - type: chart + chart-type: heatmap + width: 420 + height: 300 + x-labels: [Mon, Tue, Wed, Thu, Fri] + y-labels: [Morning, Afternoon, Evening] + cell-values: true + series: + - data: [2, 8, 4, 10, 6] + - data: [5, 3, 9, 7, 1] + - data: [8, 6, 2, 4, 9] + title: Activity + legend: none + palette: ocean + """); + await AssertSnapshot("chart_heatmap_light", template, new ObjectValue()); + } + + [Fact] + public async Task RadarChart_Dark() + { + var template = Parser.Parse(""" + canvas: + width: 360 + background: "#1e1e1e" + layout: + - type: chart + chart-type: radar + width: 360 + height: 360 + categories: [Speed, Power, Range, Agility, Armor, Stealth] + series: + - label: Alpha + data: [4, 3, 5, 2, 4, 3] + - label: Bravo + data: [3, 5, 2, 4, 3, 5] + theme: dark + legend: bottom + palette: vivid + """); + await AssertSnapshot("chart_radar_dark", template, new ObjectValue()); + } + [Fact] public async Task EmptyChart_NoDataPlaceholder() { diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_heatmap_light.png b/tests/FlexRender.Tests/Snapshots/golden/chart_heatmap_light.png new file mode 100644 index 0000000000000000000000000000000000000000..25ba2a37a18fda69b24d2dc732dd34b35c704383 GIT binary patch literal 11176 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 literal 0 HcmV?d00001 diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_radar_dark.png b/tests/FlexRender.Tests/Snapshots/golden/chart_radar_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4f34c83a82b27ffe30bf47ec77926b9608bff5b0 GIT binary patch literal 30517 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ⅈmpL}2rpKQSh0j0|P#Se|DHNtG!0 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 Date: Sat, 13 Jun 2026 22:51:19 +0400 Subject: [PATCH 089/119] docs(playground): add heatmap/radar chart types and grid props to schema --- .../wwwroot/schemas/flexrender-template.json | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json b/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json index 55c9beb..fb14782 100644 --- a/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json +++ b/src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json @@ -871,7 +871,7 @@ }, "chartElement": { - "description": "Renders a bar, line, area, pie, or donut chart. Empty/missing series renders a 'no data' placeholder.", + "description": "Renders a bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline, heatmap, or radar chart. Empty/missing series renders a 'no data' placeholder.", "allOf": [ { "$ref": "#/definitions/flexItemProperties" } ], @@ -880,7 +880,7 @@ "chart-type": { "description": "Chart kind.", "type": "string", - "enum": ["bar", "line", "area", "pie", "donut", "scatter", "bubble", "gauge", "progress", "sparkline"] + "enum": ["bar", "line", "area", "pie", "donut", "scatter", "bubble", "gauge", "progress", "sparkline", "heatmap", "radar"] }, "categories": { "description": "X-axis category labels (bar/line/area) or slice labels (pie/donut).", @@ -961,6 +961,20 @@ "label": { "description": "Gauge/progress only. Centered caption under the value.", "type": "string" + }, + "x-labels": { + "description": "Heatmap only. Column (x-axis) labels, left to right.", + "type": "array", + "items": { "type": "string" } + }, + "y-labels": { + "description": "Heatmap only. Row (y-axis) labels, top to bottom (one per series/row).", + "type": "array", + "items": { "type": "string" } + }, + "cell-values": { + "description": "Heatmap only. Draw each cell's numeric value inside the cell.", + "type": "boolean" } } }, From 3823f8c99b5e0d593a34800058591657f4ffbcbd Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 22:52:43 +0400 Subject: [PATCH 090/119] docs(wiki): document heatmap and radar chart types --- docs/wiki/Element-Reference.md | 11 +++- docs/wiki/Visual-Reference.md | 51 +++++++++++++++++- examples/visual-docs/chart/heatmap-light.yaml | 26 +++++++++ examples/visual-docs/chart/radar-dark.yaml | 25 +++++++++ .../output/chart-heatmap-light.png | Bin 0 -> 11176 bytes .../visual-docs/output/chart-radar-dark.png | Bin 0 -> 30517 bytes 6 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 examples/visual-docs/chart/heatmap-light.yaml create mode 100644 examples/visual-docs/chart/radar-dark.yaml create mode 100644 examples/visual-docs/output/chart-heatmap-light.png create mode 100644 examples/visual-docs/output/chart-radar-dark.png diff --git a/docs/wiki/Element-Reference.md b/docs/wiki/Element-Reference.md index 0045579..e89137d 100644 --- a/docs/wiki/Element-Reference.md +++ b/docs/wiki/Element-Reference.md @@ -1459,7 +1459,7 @@ Z # close path ## chart -A declarative chart element that renders `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, and `sparkline` charts into a fixed `width` by `height` box. It participates in flex layout like any other element. Series data may be supplied inline or bound from the data context with `{{expressions}}` -- the same binding used by `table` rows -- so charts can be driven directly by a data file. A chart with no series (or empty data) renders a centered "no data" placeholder instead of failing. +A declarative chart element that renders `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, `sparkline`, `heatmap`, and `radar` charts into a fixed `width` by `height` box. It participates in flex layout like any other element. Series data may be supplied inline or bound from the data context with `{{expressions}}` -- the same binding used by `table` rows -- so charts can be driven directly by a data file. A chart with no series (or empty data) renders a centered "no data" placeholder instead of failing. ### Minimal Example @@ -1477,7 +1477,7 @@ A declarative chart element that renders `bar`, `line`, `area`, `pie`, `donut`, | Property | YAML Name | Type | Default | Valid Values | Required | Description | |----------|-----------|------|---------|--------------|----------|-------------| -| ChartType | `chart-type` | string | `bar` | bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline | No | Chart kind. | +| ChartType | `chart-type` | string | `bar` | bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline, heatmap, radar | No | Chart kind. | | Width | `width` | number | none | px | Yes | Chart width in pixels. | | Height | `height` | number | none | px | Yes | Chart height in pixels. | | Categories | `categories` | list | `[]` | List of strings | No | X-axis category labels (bar/line/area) or slice labels (pie/donut). | @@ -1494,9 +1494,16 @@ A declarative chart element that renders `bar`, `line`, `area`, `pie`, `donut`, | Value | `value` | number | none | Any number | No | **Gauge/progress only.** Indicator value. | | Max | `max` | number | `100` | Any number | No | **Gauge/progress only.** Indicator maximum. | | ValueLabel | `label` | string | none | Any string | No | **Gauge/progress only.** Centered caption under the value. | +| XLabels | `x-labels` | list | `[]` | List of strings | No | **Heatmap only.** Column (x-axis) labels; falls back to `categories`. | +| YLabels | `y-labels` | list | `[]` | List of strings | No | **Heatmap only.** Row (y-axis) labels, one per series/row. | +| ShowCellValues | `cell-values` | bool | `false` | true, false | No | **Heatmap only.** Draw each cell's numeric value inside the cell. | > **Scatter / bubble data:** supply `series[].data` as an array of tuples -- `[[x, y], ...]` for `scatter` and `[[x, y, r], ...]` for `bubble`, where `r` sizes the bubble. Tuple data is inline-only in this release (expression-bound tuples are not yet supported). Sparkline uses a plain numeric `data` array and honors `smooth`/`points`; it renders chrome-free (no axes, legend, or title). +> **Heatmap data:** each entry in `series` is one ROW; `series[i].data` holds that row's column values. `x-labels` label the columns (or `categories` if `x-labels` is omitted) and `y-labels` label the rows. Cell color encodes the value, interpolated between the palette's first ("low") and second ("high") colors; set `cell-values: true` to print the number in each cell. + +> **Radar data:** `categories` become the spokes (one axis per category, starting at the top and advancing clockwise). Each entry in `series` is a closed polygon connecting that series' values along the spokes -- filled semi-transparently and stroked. Values share one radial scale anchored at zero at the center. + > **Resource limits:** charts are bounded by `ResourceLimits.MaxSeriesPerChart` (default `50`) and `ResourceLimits.MaxDataPointsPerSeries` (default `10000`). ### Data Binding Example diff --git a/docs/wiki/Visual-Reference.md b/docs/wiki/Visual-Reference.md index 1cff69a..522ba48 100644 --- a/docs/wiki/Visual-Reference.md +++ b/docs/wiki/Visual-Reference.md @@ -737,7 +737,7 @@ layout: ### Charts -The `chart` element renders `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, and `sparkline` charts into a fixed `width` by `height` box. Series `data` is either an inline number array or a `{{ expression }}` bound from the data context (like `table` rows). Scatter and bubble take tuple data (`[[x, y], ...]` / `[[x, y, r], ...]`); gauge and progress are single-value indicators driven by `value`/`max`/`label`; sparkline is a chrome-free inline line. The `palette` (named or explicit color list) and `theme` (`light`, `dark`, `minimal`) control colors. An empty/missing `series` renders a "no data" placeholder rather than an error. +The `chart` element renders `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, `sparkline`, `heatmap`, and `radar` charts into a fixed `width` by `height` box. Series `data` is either an inline number array or a `{{ expression }}` bound from the data context (like `table` rows). Scatter and bubble take tuple data (`[[x, y], ...]` / `[[x, y, r], ...]`); gauge and progress are single-value indicators driven by `value`/`max`/`label`; sparkline is a chrome-free inline line; heatmap renders a labeled grid (each series is a row) and radar plots `categories` as spokes. The `palette` (named or explicit color list) and `theme` (`light`, `dark`, `minimal`) control colors. An empty/missing `series` renders a "no data" placeholder rather than an error. #### Bar @@ -952,6 +952,55 @@ layout: ![Sparkline chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-sparkline-light.png) +Heatmap renders a grid where each `series` entry is one ROW: `series[i].data` holds that row's column values. `x-labels` label the columns (falling back to `categories`), `y-labels` label the rows, and `cell-values: true` prints each cell's number. Cell color is interpolated between the palette's first ("low") and second ("high") colors. + +```yaml +canvas: + width: 420 + background: "#ffffff" +layout: + - type: chart + chart-type: heatmap + width: 420 + height: 300 + x-labels: [Mon, Tue, Wed, Thu, Fri] + y-labels: [Morning, Afternoon, Evening] + cell-values: true + series: + - data: [2, 8, 4, 10, 6] + - data: [5, 3, 9, 7, 1] + - data: [8, 6, 2, 4, 9] + title: Activity + legend: none + palette: ocean +``` + +![Heatmap chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-heatmap-light.png) + +Radar uses `categories` as spokes (one axis per category, starting at the top and advancing clockwise). Each `series` is a closed polygon along the spokes -- filled semi-transparently and stroked -- sharing one radial scale anchored at zero at the center. + +```yaml +canvas: + width: 360 + background: "#1e1e1e" +layout: + - type: chart + chart-type: radar + width: 360 + height: 360 + categories: [Speed, Power, Range, Agility, Armor, Stealth] + series: + - label: Alpha + data: [4, 3, 5, 2, 4, 3] + - label: Bravo + data: [3, 5, 2, 4, 3, 5] + theme: dark + legend: bottom + palette: vivid +``` + +![Radar chart](https://media.githubusercontent.com/media/RoboNET/FlexRender/main/examples/visual-docs/output/chart-radar-dark.png) + --- ## Renderer Backend Comparison diff --git a/examples/visual-docs/chart/heatmap-light.yaml b/examples/visual-docs/chart/heatmap-light.yaml new file mode 100644 index 0000000..6266397 --- /dev/null +++ b/examples/visual-docs/chart/heatmap-light.yaml @@ -0,0 +1,26 @@ +template: + name: "chart-heatmap-light" + version: 1 + +fonts: + default: "../../assets/fonts/Inter-Regular.ttf" + +canvas: + width: 420 + background: "#ffffff" + +layout: + - type: chart + chart-type: heatmap + width: 420 + height: 300 + x-labels: [Mon, Tue, Wed, Thu, Fri] + y-labels: [Morning, Afternoon, Evening] + cell-values: true + series: + - data: [2, 8, 4, 10, 6] + - data: [5, 3, 9, 7, 1] + - data: [8, 6, 2, 4, 9] + title: Activity + legend: none + palette: ocean diff --git a/examples/visual-docs/chart/radar-dark.yaml b/examples/visual-docs/chart/radar-dark.yaml new file mode 100644 index 0000000..5fd074f --- /dev/null +++ b/examples/visual-docs/chart/radar-dark.yaml @@ -0,0 +1,25 @@ +template: + name: "chart-radar-dark" + version: 1 + +fonts: + default: "../../assets/fonts/Inter-Regular.ttf" + +canvas: + width: 360 + background: "#1e1e1e" + +layout: + - type: chart + chart-type: radar + width: 360 + height: 360 + categories: [Speed, Power, Range, Agility, Armor, Stealth] + series: + - label: Alpha + data: [4, 3, 5, 2, 4, 3] + - label: Bravo + data: [3, 5, 2, 4, 3, 5] + theme: dark + legend: bottom + palette: vivid diff --git a/examples/visual-docs/output/chart-heatmap-light.png b/examples/visual-docs/output/chart-heatmap-light.png new file mode 100644 index 0000000000000000000000000000000000000000..25ba2a37a18fda69b24d2dc732dd34b35c704383 GIT binary patch literal 11176 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 literal 0 HcmV?d00001 diff --git a/examples/visual-docs/output/chart-radar-dark.png b/examples/visual-docs/output/chart-radar-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4f34c83a82b27ffe30bf47ec77926b9608bff5b0 GIT binary patch literal 30517 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ⅈmpL}2rpKQSh0j0|P#Se|DHNtG!0 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 Date: Sat, 13 Jun 2026 22:53:47 +0400 Subject: [PATCH 091/119] docs: document heatmap and radar in llms files --- llms-full.txt | 39 +++++++++++++++++++++++++++++++++++++-- llms.txt | 2 +- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index 380ef3a..1a4efe9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -963,11 +963,11 @@ Example: `d: "M 0 0 L 100 50 Q 150 0 200 50 Z"` ## Element Type: chart (ChartElement) -A declarative chart element that renders bar, line, area, pie, donut, scatter, bubble, gauge, progress, and sparkline charts. The chart paints into its `width` by `height` box and participates in flex layout like any other element. Both `width` and `height` are required. +A declarative chart element that renders bar, line, area, pie, donut, scatter, bubble, gauge, progress, sparkline, heatmap, and radar charts. The chart paints into its `width` by `height` box and participates in flex layout like any other element. Both `width` and `height` are required. | Property | Type | Default | Description | |----------|------|---------|-------------| -| chart-type | string | `bar` | Chart kind: `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, `sparkline` | +| chart-type | string | `bar` | Chart kind: `bar`, `line`, `area`, `pie`, `donut`, `scatter`, `bubble`, `gauge`, `progress`, `sparkline`, `heatmap`, `radar` | | width | number | none | Chart width in pixels (**required**) | | height | number | none | Chart height in pixels (**required**) | | categories | string[] | `[]` | X-axis category labels (bar/line/area) or slice labels (pie/donut) | @@ -984,6 +984,9 @@ A declarative chart element that renders bar, line, area, pie, donut, scatter, b | value | number | none | **Gauge/progress only.** The indicator value | | max | number | `100` | **Gauge/progress only.** The indicator maximum | | label | string | none | **Gauge/progress only.** Centered caption under the value | +| x-labels | string[] | `[]` | **Heatmap only.** Column (x-axis) labels; falls back to `categories` if omitted | +| y-labels | string[] | `[]` | **Heatmap only.** Row (y-axis) labels, one per series/row | +| cell-values | bool | `false` | **Heatmap only.** Draw each cell's numeric value inside the cell | ### Series and Data Binding @@ -1046,6 +1049,38 @@ series: - data: [3, 8, 4, 10, 6, 9, 5, 11] ``` +### Phase-4 Chart Types + +Heatmap takes 2D data: each `series` entry is one ROW (`series[i].data` = that row's column values), `x-labels` label the columns (or `categories` if omitted), `y-labels` label the rows, and `cell-values: true` prints each cell's number. Cell color is interpolated between the palette's first ("low") and second ("high") colors. Radar uses `categories` as spokes (one axis per category, starting at the top); each `series` is a closed polygon along the spokes, filled semi-transparently and stroked, sharing one radial scale anchored at zero at the center. + +```yaml +- type: chart + chart-type: heatmap + width: 420 + height: 300 + x-labels: [Mon, Tue, Wed, Thu, Fri] + y-labels: [Morning, Afternoon, Evening] + cell-values: true + series: + - data: [2, 8, 4, 10, 6] + - data: [5, 3, 9, 7, 1] + - data: [8, 6, 2, 4, 9] + legend: none + palette: ocean +- type: chart + chart-type: radar + width: 360 + height: 360 + categories: [Speed, Power, Range, Agility, Armor, Stealth] + series: + - label: Alpha + data: [4, 3, 5, 2, 4, 3] + - label: Bravo + data: [3, 5, 2, 4, 3, 5] + legend: bottom + palette: vivid +``` + ### Palettes and Themes The `palette` selects series colors. Use one of the named palettes (`default`, `ocean`, `sunset`, `forest`, `mono`, `vivid`) or supply an explicit list of hex colors that is applied to series/slices in order. The `theme` (`light`, `dark`, `minimal`) controls the chart's background, axis, grid, and text colors; it overrides the template-level theme for this element only. diff --git a/llms.txt b/llms.txt index 1462fd6..4754b0e 100644 --- a/llms.txt +++ b/llms.txt @@ -89,7 +89,7 @@ Sixteen element types, each a sealed class extending `TemplateElement`: | **circle** | fill (hex color OR gradient object), stroke, stroke-width, opacity, size (shorthand setting width & height = diameter) -- flex box drawn as a circle | | **ellipse** | fill (hex color OR gradient object), stroke, stroke-width, opacity -- flex box drawn as an ellipse filling its width/height | | **draw** | shapes (ordered list painted in list order); shape kinds: line, polyline, rect, circle, path (abs commands M/L/Q/C/Z); absolute coords relative to element top-left; capped by `MaxShapesPerDraw` (default 1000). Box shapes accept gradient fill objects | -| **chart** | chart-type (bar/line/area/pie/donut/scatter/bubble/gauge/progress/sparkline, default bar), width/height (required), categories (x-axis/slice labels), series (list of {label?, data}; data = inline number array, OR `[[x,y],...]`/`[[x,y,r],...]` tuples for scatter/bubble, OR `{{ expr }}` resolving to a number array), palette (named default/ocean/sunset/forest/mono/vivid OR explicit `["#hex",...]`), theme (light/dark/minimal override), legend (top/bottom/left/right/none, default bottom), title; bar only: horizontal, stacked; line/area/sparkline: smooth, points; pie/donut: labels (percent/value/none); gauge/progress only: value, max (default 100), label (centered caption). Sparkline is a chrome-free inline line. Empty/missing series renders a "no data" placeholder, never an error. Capped by `MaxSeriesPerChart` (50) and `MaxDataPointsPerSeries` (10000) | +| **chart** | chart-type (bar/line/area/pie/donut/scatter/bubble/gauge/progress/sparkline/heatmap/radar, default bar), width/height (required), categories (x-axis/slice labels; radar spokes), series (list of {label?, data}; data = inline number array, OR `[[x,y],...]`/`[[x,y,r],...]` tuples for scatter/bubble, OR `{{ expr }}` resolving to a number array), palette (named default/ocean/sunset/forest/mono/vivid OR explicit `["#hex",...]`), theme (light/dark/minimal override), legend (top/bottom/left/right/none, default bottom), title; bar only: horizontal, stacked; line/area/sparkline: smooth, points; pie/donut: labels (percent/value/none); gauge/progress only: value, max (default 100), label (centered caption); heatmap only: x-labels, y-labels, cell-values (print number per cell). Sparkline is a chrome-free inline line. Heatmap: each series is a grid ROW, x-labels/y-labels label columns/rows (x-labels falls back to categories), cell color encodes value. Radar: categories are spokes, each series is a closed polygon. Empty/missing series renders a "no data" placeholder, never an error. Capped by `MaxSeriesPerChart` (50) and `MaxDataPointsPerSeries` (10000) | | **table** | array, as, columns (key/label/width/grow/align/format), rows, headerFont/headerFontWeight/headerFontStyle/headerFontFamily/headerColor/headerSize/headerBackground -- expands to flex tree | | **each** | array, as, children -- iteration over data arrays | | **if** | condition, equals/notEquals, then, elseIf, else -- conditional rendering | From 3f08b9323938102d5a0cecab1069c2968416cec0 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:03:10 +0400 Subject: [PATCH 092/119] docs: add FlexRender.Xml parser phase 5 implementation plan --- .../plans/2026-06-13-xml-parser-phase5.md | 1981 +++++++++++++++++ 1 file changed, 1981 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-13-xml-parser-phase5.md diff --git a/docs/superpowers/plans/2026-06-13-xml-parser-phase5.md b/docs/superpowers/plans/2026-06-13-xml-parser-phase5.md new file mode 100644 index 0000000..104acf0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-xml-parser-phase5.md @@ -0,0 +1,1981 @@ +# FlexRender.Xml Parser (Phase 5) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new `FlexRender.Xml` package that parses an XML template syntax into the exact same `Template` AST the YAML parser produces, via the same `ITemplateParser` abstraction, with zero new elements and zero renderer changes. + +**Architecture:** XML is translated into the same `YamlDotNet.RepresentationModel.YamlMappingNode` document tree that `FlexRender.Yaml.TemplateParser` already consumes internally, then handed to a new `internal` entry point on `TemplateParser` so that **all** existing element/chart/shape parsing, `KnownProperties` validation, typo suggestions, and `ResourceLimits` enforcement are reused verbatim. `FlexRender.Xml` references `FlexRender.Yaml`; `FlexRender.Yaml` grants `InternalsVisibleTo("FlexRender.Xml")`. Only the XML→node mapping, the public `XmlTemplateParser : ITemplateParser`, `RenderXml` extension methods, and docs are new. + +**Tech Stack:** .NET (net8.0;net10.0), `System.Xml.Linq` (`XDocument`/`XElement` — AOT-safe, no reflection, NO `XmlSerializer`), YamlDotNet RepresentationModel (already a transitive dependency via `FlexRender.Yaml`), xUnit v3 + AwesomeAssertions for tests. + +--- + +## XML Syntax Mapping (LOCKED DECISION) + +The XML parser does **not** reimplement element parsing. It converts the XML tree into the equivalent `YamlMappingNode` structure and reuses the YAML element parsers. The mapping rules below define that conversion. They are designed to be LLM-friendly (attributes over indentation) while round-tripping cleanly to the existing AST. + +### Document shape + +| YAML | XML | +| --- | --- | +| top-level `template:`, `canvas:`, `fonts:`, `layout:` | a single root element `` | +| `canvas: { width: 300, ... }` | `` child of `` | +| `template: { name, version, culture }` | attributes on ``: `name`, `version`, `culture` | +| `layout: [ ...elements ]` | every **other** child element of `` (in order) is a layout element | +| `fonts: { default: "x.ttf" }` | `` child containing `` entries | + +### Element shape + +- **Element type = XML local element name.** `` → `type: text`, `` → `type: flex`, ``, ``, ``, ``, ``, etc. The set of names is exactly the YAML element types. +- **Scalar properties = XML attributes.** `` → `size: 1em`, `color: "#ff0000"`. Attribute names are identical to YAML property names (kebab-case and camelCase both pass through unchanged, e.g. `chart-type`, `stroke-width`, `min-width`, `borderColor`). Validation against `KnownProperties` is unchanged. +- **`text` content** may be given either as the `content` attribute **or** as the element's inner text: `Hello` ≡ ``. If both are present, the `content` attribute wins. (svg `content` follows the same rule.) +- **Child-element containers** map to YAML list properties. The container is expressed by repeating child elements directly inside the parent — no wrapper: + - `flex` children → child layout elements directly inside ``. + - `each` body → child layout elements directly inside `` (YAML `children`). + - `if` → `` and `` wrapper children, each holding layout elements; optional `` wrapper holds a single nested `if` (YAML `elseIf`). Comparison operators (`equals`, `in`, `greaterThan`, …) remain attributes on ``. + - `table` → `` wrapper holding `` entries; optional `` wrapper holding `` entries. Column/row fields are attributes. + - `chart` → `` entries (each a child of ``), `` wrapper holding `value` entries, ``/`` wrappers holding `` entries, `` wrapper holding `#fff` entries (or a `palette="ocean"` attribute for a named palette). + - `draw` → `` wrapper holding one-shape-per-child elements ``, ``, ``, ``, ``. +- **Scalar lists** (chart `series.data`, `categories`, polyline `points`): + - `series` numeric data: `data="12,30,22,48"` attribute (comma-separated) → YAML `data: [12,30,22,48]`. An expression `data="{{ sales }}"` passes through as a scalar string. + - tuple data (scatter/bubble): `points="1,2; 3,4; 5,6"` attribute on `` → YAML `data: [[1,2],[3,4],[5,6]]`. (Semicolon separates points, comma separates components.) + - `draw polyline` `points="10,10; 50,50"` → YAML `points: [[10,10],[50,50]]`. + +### How the converter decides attribute vs. list-container + +The converter is generic and data-driven. For each XML element it produces a `YamlMappingNode` with: +1. `type` = the element local-name (except the special wrappers below). +2. one scalar entry per attribute (name → value), with comma/semicolon list attributes expanded into `YamlSequenceNode` when the attribute name is a known list property (`data`, `points`, `categories`, `palette`, `x-labels`, `y-labels`). +3. one entry per recognised **child wrapper**: child elements named `then`/`else`/`else-if`/`columns`/`rows`/`series`/`categories`/`x-labels`/`y-labels`/`palette`/`shapes` become the corresponding YAML sequence/mapping; all **other** child elements are collected into the container's natural list key (`children` for `flex`/`each`, `layout` for the root, the shape list for `draw`, etc.). + +### Side-by-side example (flex + chart) + +YAML: + +```yaml +canvas: + width: 400 +layout: + - type: flex + direction: row + gap: 8 + children: + - type: text + content: "Quarterly sales" + size: 1.2em + - type: chart + chart-type: bar + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: [12, 30, 22, 48] + - label: "2025" + data: [18, 26, 31, 40] +``` + +XML: + +```xml + + + + Quarterly sales + + + Q1Q2Q3Q4 + + + + + + +``` + +Both parse to the identical `Template` AST. + +--- + +## File Structure + +- `src/FlexRender.Xml/FlexRender.Xml.csproj` — new package; references `FlexRender.Yaml`. +- `src/FlexRender.Xml/XmlTemplateParser.cs` — public `ITemplateParser` for XML. +- `src/FlexRender.Xml/XmlToYamlNodeConverter.cs` — internal XElement → YamlMappingNode tree converter (all the mapping rules above). +- `src/FlexRender.Xml/XmlFlexRenderExtensions.cs` — `RenderXml` extension methods (mirror `RenderYaml`). +- `src/FlexRender.Yaml/Parsing/TemplateParser.cs` — add `internal Template ParseDocumentRoot(YamlMappingNode root)` + `InternalsVisibleTo`. +- `tests/FlexRender.Tests/Parsing/Xml/*.cs` — XML parser tests + YAML-vs-XML AST cross-checks. +- Docs: `docs/wiki/Xml-Syntax.md`, `AGENTS.md`, `llms.txt`, `llms-full.txt`. + +--- + +### Task 1: Create FlexRender.Xml project and wire it into the solution + +**Files:** +- Create: `src/FlexRender.Xml/FlexRender.Xml.csproj` +- Create: `src/FlexRender.Xml/Placeholder.cs` +- Modify: `FlexRender.slnx` + +- [ ] **Step 1: Create the project file** + +Create `src/FlexRender.Xml/FlexRender.Xml.csproj`: + +```xml + + + + FlexRender.Xml + XML template parsing for FlexRender (alternative to YAML). + true + + + + + + + +``` + +(No extra `PackageReference` is needed: `System.Xml.Linq` is in the framework reference, and YamlDotNet comes transitively through `FlexRender.Yaml`.) + +- [ ] **Step 2: Create a temporary placeholder type so the project compiles** + +Create `src/FlexRender.Xml/Placeholder.cs`: + +```csharp +namespace FlexRender.Xml; + +/// +/// Temporary placeholder so the empty project compiles. Removed in a later task. +/// +internal static class Placeholder +{ +} +``` + +- [ ] **Step 3: Register the project in the solution** + +In `FlexRender.slnx`, inside the `` element (which currently contains only `FlexRender.Yaml`), add a second project line directly after the existing one so the folder reads: + +```xml + + + + +``` + +- [ ] **Step 4: Build the solution** + +Run: `dotnet build FlexRender.slnx` +Expected: Build succeeded, 0 errors. `FlexRender.Xml` appears in the build output. + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Xml/FlexRender.Xml.csproj src/FlexRender.Xml/Placeholder.cs FlexRender.slnx +git commit --no-gpg-sign -m "build: scaffold FlexRender.Xml project" +``` + +--- + +### Task 2: Expose an internal AST entry point on TemplateParser + +This lets `FlexRender.Xml` reuse all element/chart/shape parsing, `KnownProperties` validation, and `ResourceLimits` without duplication. `TemplateParser.Parse(string)` already builds a `YamlMappingNode root` and then parses metadata/fonts/canvas/layout from it (see lines 120-157). We extract that tail into an internal method. + +**Files:** +- Modify: `src/FlexRender.Yaml/Parsing/TemplateParser.cs` +- Create: `src/FlexRender.Yaml/AssemblyInfo.cs` +- Test: `tests/FlexRender.Tests/Parsing/Xml/InternalEntryPointTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Xml/InternalEntryPointTests.cs`: + +```csharp +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using YamlDotNet.RepresentationModel; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for the internal entry point +/// used by the XML parser to reuse YAML element parsing. +/// +public class InternalEntryPointTests +{ + /// + /// Verifies that a programmatically built YamlMappingNode root produces the same AST + /// as parsing the equivalent YAML string. + /// + [Fact] + public void ParseDocumentRoot_BuiltNode_ProducesEquivalentAst() + { + // Build: { canvas: { width: 300 }, layout: [ { type: text, content: Hi } ] } + var canvas = new YamlMappingNode(); + canvas.Add("width", "300"); + + var textNode = new YamlMappingNode(); + textNode.Add("type", "text"); + textNode.Add("content", "Hi"); + + var layout = new YamlSequenceNode(); + layout.Add(textNode); + + var root = new YamlMappingNode(); + root.Add("canvas", canvas); + root.Add("layout", layout); + + var template = new TemplateParser().ParseDocumentRootForTests(root); + + var text = Assert.IsType(Assert.Single(template.Elements)); + Assert.Equal("Hi", text.Content); + Assert.Equal(300, template.Canvas.Width); + } +} +``` + +The test calls `ParseDocumentRootForTests` — an internal test shim we add next to the real internal method (the test project already has `InternalsVisibleTo` to `FlexRender.Tests`? It does not yet; we add it in Step 3 alongside the XML one). + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~InternalEntryPointTests"` +Expected: FAIL — does not compile, `ParseDocumentRootForTests` is not defined. + +- [ ] **Step 3: Add the internal entry point and InternalsVisibleTo** + +In `src/FlexRender.Yaml/Parsing/TemplateParser.cs`, replace the body of `Parse(string content)` (the metadata/fonts/canvas/layout section, currently lines 120-157) so that after `root` is obtained it delegates to a new method. Specifically, change the `Parse(string)` method's tail (everything from `var template = new Template();` through `return template;`) to: + +```csharp + return ParseDocumentRoot(root); + } + + /// + /// Builds a from an already-parsed YAML document root. + /// Shared by the YAML string/stream entry points and by the XML parser, which + /// translates XML into the equivalent tree. + /// + /// The document root mapping node. + /// The parsed template. + /// Thrown when is null. + /// Thrown when required sections are missing or invalid. + internal Template ParseDocumentRoot(YamlMappingNode root) + { + ArgumentNullException.ThrowIfNull(root); + + var template = new Template(); + + if (TryGetMapping(root, "template", out var templateNode)) + { + template.Name = GetStringValue(templateNode, "name"); + template.Version = GetIntValue(templateNode, "version", 1); + template.Culture = GetStringValue(templateNode, "culture"); + } + + var fontsKey = new YamlScalarNode("fonts"); + if (root.Children.TryGetValue(fontsKey, out var fontsYamlNode)) + { + template.Fonts = fontsYamlNode switch + { + YamlMappingNode fontsMapping => ParseFonts(fontsMapping), + YamlSequenceNode fontsSequence => ParseFontsList(fontsSequence), + _ => throw new TemplateParseException( + "Invalid 'fonts' section. Expected a mapping (name: path) or a list of font entries.") + }; + } + + if (!TryGetMapping(root, "canvas", out var canvasNode)) + { + throw new TemplateParseException("Missing required 'canvas' section"); + } + + template.Canvas = ParseCanvas(canvasNode); + + if (TryGetSequence(root, "layout", out var layoutNode)) + { + template.Elements = ParseElements(layoutNode); + } + + return template; + } + + /// + /// Test-only shim exposing to the test assembly. + /// + /// The document root mapping node. + /// The parsed template. + internal Template ParseDocumentRootForTests(YamlMappingNode root) => ParseDocumentRoot(root); +``` + +Make sure the original `Parse(string)` no longer contains the duplicated metadata/canvas/layout block (it now ends with `return ParseDocumentRoot(root);`). + +Create `src/FlexRender.Yaml/AssemblyInfo.cs`: + +```csharp +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("FlexRender.Xml")] +[assembly: InternalsVisibleTo("FlexRender.Tests")] +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~InternalEntryPointTests"` +Expected: PASS (1 test). + +Then run the full YAML parser suite to confirm the refactor changed nothing: + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~Parsing.TemplateParser"` +Expected: PASS (all existing parser tests green). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Yaml/Parsing/TemplateParser.cs src/FlexRender.Yaml/AssemblyInfo.cs tests/FlexRender.Tests/Parsing/Xml/InternalEntryPointTests.cs +git commit --no-gpg-sign -m "refactor: extract ParseDocumentRoot entry point for parser reuse" +``` + +--- + +### Task 3: XmlTemplateParser skeleton + canvas + simple text/separator elements + +Introduce the public parser and the converter, covering the document shape, `canvas`, and the two simplest elements. Delete the placeholder. + +**Files:** +- Create: `src/FlexRender.Xml/XmlToYamlNodeConverter.cs` +- Create: `src/FlexRender.Xml/XmlTemplateParser.cs` +- Delete: `src/FlexRender.Xml/Placeholder.cs` +- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlTemplateParserBasicTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Xml/XmlTemplateParserBasicTests.cs`: + +```csharp +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for basic XML template parsing (canvas, text, separator). +/// +public class XmlTemplateParserBasicTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_CanvasAndText_ContentAttribute() + { + const string xml = """ + + + + + """; + + var template = _parser.Parse(xml); + + Assert.Equal(300, template.Canvas.Width); + var text = Assert.IsType(Assert.Single(template.Elements)); + Assert.Equal("Hello World", text.Content); + Assert.Equal("1.5em", text.Size.Value); + Assert.Equal("#ff0000", text.Color.Value); + } + + [Fact] + public void Parse_TextInnerTextUsedAsContent() + { + const string xml = """ + + + Inline body + + """; + + var text = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal("Inline body", text.Content); + } + + [Fact] + public void Parse_Separator() + { + const string xml = """ + + + + + """; + + var sep = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(SeparatorOrientation.Horizontal, sep.Orientation); + Assert.Equal(SeparatorStyle.Dashed, sep.Style); + Assert.Equal(2f, sep.Thickness); + } + + [Fact] + public void Parse_NullContent_Throws() + { + Assert.Throws(() => _parser.Parse((string)null!)); + } + + [Fact] + public void Parse_MissingCanvas_Throws() + { + const string xml = ""; + var ex = Assert.Throws(() => _parser.Parse(xml)); + Assert.Contains("canvas", ex.Message, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Parse_MalformedXml_ThrowsTemplateParseException() + { + const string xml = ""; + Assert.Throws(() => _parser.Parse(xml)); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlTemplateParserBasicTests"` +Expected: FAIL — `XmlTemplateParser` does not exist. + +- [ ] **Step 3: Implement the converter and parser** + +Delete the placeholder: `git rm src/FlexRender.Xml/Placeholder.cs` (or remove the file). + +Create `src/FlexRender.Xml/XmlToYamlNodeConverter.cs`: + +```csharp +using System.Globalization; +using System.Xml.Linq; +using FlexRender.Parsing; +using YamlDotNet.RepresentationModel; + +namespace FlexRender.Xml; + +/// +/// Converts a FlexRender XML template tree into the equivalent +/// document root that the YAML +/// consumes, so all element/chart/shape parsing and +/// validation is reused without duplication. +/// +internal static class XmlToYamlNodeConverter +{ + /// The root element local-name. + private const string RootName = "flexrender"; + + /// + /// Wrapper child element names that map to dedicated YAML list/branch keys rather than + /// being treated as nested layout elements. + /// + private static readonly HashSet WrapperNames = new(StringComparer.Ordinal) + { + "then", "else", "else-if", "columns", "rows", "series", + "categories", "x-labels", "y-labels", "palette", "shapes" + }; + + /// + /// Attribute names whose comma/semicolon values expand into YAML sequences. + /// + private static readonly HashSet ListAttributes = new(StringComparer.Ordinal) + { + "data", "points", "categories", "palette", "x-labels", "y-labels" + }; + + /// + /// Parses the XML string and builds the YAML document root mapping. + /// + /// The raw XML template. + /// The equivalent document root mapping node. + /// Thrown when the XML is malformed or the root element is wrong. + internal static YamlMappingNode Convert(string xml) + { + XDocument doc; + try + { + doc = XDocument.Parse(xml, LoadOptions.None); + } + catch (System.Xml.XmlException ex) + { + throw new TemplateParseException($"Invalid XML: {ex.Message}", ex); + } + + var rootEl = doc.Root + ?? throw new TemplateParseException("XML template has no root element."); + + if (!string.Equals(rootEl.Name.LocalName, RootName, StringComparison.Ordinal)) + { + throw new TemplateParseException( + $"XML template root must be <{RootName}>, but was <{rootEl.Name.LocalName}>."); + } + + var root = new YamlMappingNode(); + + // template metadata from root attributes + var metadata = new YamlMappingNode(); + AddAttrIfPresent(rootEl, "name", metadata); + AddAttrIfPresent(rootEl, "version", metadata); + AddAttrIfPresent(rootEl, "culture", metadata); + if (metadata.Children.Count > 0) + { + root.Add("template", metadata); + } + + var layout = new YamlSequenceNode(); + + foreach (var child in rootEl.Elements()) + { + var localName = child.Name.LocalName; + switch (localName) + { + case "canvas": + root.Add("canvas", AttributesToMapping(child)); + break; + case "fonts": + root.Add("fonts", ConvertFonts(child)); + break; + default: + layout.Add(ConvertElement(child)); + break; + } + } + + root.Add("layout", layout); + return root; + } + + /// + /// Builds a mapping node containing only the element's attributes (no type, no children). + /// + private static YamlMappingNode AttributesToMapping(XElement el) + { + var node = new YamlMappingNode(); + foreach (var attr in el.Attributes()) + { + node.Add(attr.Name.LocalName, new YamlScalarNode(attr.Value)); + } + return node; + } + + /// + /// Converts a single layout element (recursively) into a YAML mapping node with a + /// type entry, scalar attributes, and child-derived list properties. + /// + private static YamlMappingNode ConvertElement(XElement el) + { + var type = el.Name.LocalName; + var node = new YamlMappingNode(); + node.Add("type", new YamlScalarNode(type)); + + // Attributes -> scalar or list entries. + foreach (var attr in el.Attributes()) + { + var name = attr.Name.LocalName; + if (ListAttributes.Contains(name)) + { + node.Add(name, ExpandListAttribute(attr.Value)); + } + else + { + node.Add(name, new YamlScalarNode(attr.Value)); + } + } + + // Inner text -> content (only when no content attribute and there are no child elements). + if (el.Attribute("content") is null && !el.HasElements) + { + var inner = el.Value; + if (!string.IsNullOrWhiteSpace(inner)) + { + node.Add("content", new YamlScalarNode(inner.Trim())); + } + } + + // Child elements. + var naturalList = new YamlSequenceNode(); + foreach (var child in el.Elements()) + { + var childName = child.Name.LocalName; + if (WrapperNames.Contains(childName)) + { + AddWrapper(node, type, child); + } + else + { + naturalList.Add(ConvertElement(child)); + } + } + + if (naturalList.Children.Count > 0) + { + node.Add(NaturalListKey(type), naturalList); + } + + return node; + } + + /// + /// Maps an element type to the YAML key its directly-nested layout children belong under. + /// + private static string NaturalListKey(string type) => type switch + { + "each" => "children", + _ => "children" // flex and any other container use 'children' + }; + + /// + /// Adds a recognised wrapper child (then/else/columns/series/...) to the node under its YAML key. + /// + private static void AddWrapper(YamlMappingNode node, string parentType, XElement wrapper) + { + var name = wrapper.Name.LocalName; + switch (name) + { + case "then": + case "else": + node.Add(name, ConvertElementSequence(wrapper)); + break; + case "else-if": + var inner = wrapper.Elements().FirstOrDefault(); + if (inner is not null) + { + node.Add("elseIf", ConvertElement(inner)); + } + break; + case "columns": + node.Add("columns", ConvertAttributeItemSequence(wrapper)); + break; + case "rows": + node.Add("rows", ConvertAttributeItemSequence(wrapper)); + break; + case "series": + // Handled at element level for charts; placeholder (overridden in chart task). + break; + case "categories": + case "x-labels": + case "y-labels": + node.Add(name, ConvertScalarItemSequence(wrapper)); + break; + case "palette": + node.Add("palette", ConvertScalarItemSequence(wrapper)); + break; + case "shapes": + // Handled in the draw task. + break; + } + } + + /// Converts child layout elements of a wrapper into a YAML sequence of mappings. + private static YamlSequenceNode ConvertElementSequence(XElement wrapper) + { + var seq = new YamlSequenceNode(); + foreach (var child in wrapper.Elements()) + { + seq.Add(ConvertElement(child)); + } + return seq; + } + + /// Converts child elements whose attributes become mapping fields (table column/row). + private static YamlSequenceNode ConvertAttributeItemSequence(XElement wrapper) + { + var seq = new YamlSequenceNode(); + foreach (var child in wrapper.Elements()) + { + seq.Add(AttributesToMapping(child)); + } + return seq; + } + + /// Converts <item>value</item> / <color> children into a scalar sequence. + private static YamlSequenceNode ConvertScalarItemSequence(XElement wrapper) + { + var seq = new YamlSequenceNode(); + foreach (var child in wrapper.Elements()) + { + seq.Add(new YamlScalarNode(child.Value.Trim())); + } + return seq; + } + + /// Converts a <fonts> wrapper into a YAML sequence of font entry mappings. + private static YamlSequenceNode ConvertFonts(XElement fonts) + { + var seq = new YamlSequenceNode(); + foreach (var font in fonts.Elements()) + { + seq.Add(AttributesToMapping(font)); + } + return seq; + } + + /// + /// Expands a comma-separated (or "x,y; x,y" tuple) attribute value into a YAML sequence. + /// A value containing a template expression is left as a scalar. + /// + private static YamlNode ExpandListAttribute(string value) + { + if (value.Contains("{{", StringComparison.Ordinal)) + { + return new YamlScalarNode(value); + } + + // Tuple list: "1,2; 3,4" -> [[1,2],[3,4]] + if (value.Contains(';', StringComparison.Ordinal)) + { + var outer = new YamlSequenceNode(); + foreach (var part in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var inner = new YamlSequenceNode(); + foreach (var comp in part.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + inner.Add(new YamlScalarNode(comp)); + } + outer.Add(inner); + } + return outer; + } + + var seq = new YamlSequenceNode(); + foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + seq.Add(new YamlScalarNode(item)); + } + return seq; + } + + /// Adds an XML attribute to a mapping node when present and non-empty. + private static void AddAttrIfPresent(XElement el, string name, YamlMappingNode node) + { + var attr = el.Attribute(name); + if (attr is not null && !string.IsNullOrEmpty(attr.Value)) + { + node.Add(name, new YamlScalarNode(attr.Value)); + } + } + + /// Unused parameter guard for invariant-culture formatting helpers (kept for future numeric formatting). + private static string FormatInvariant(double value) => value.ToString(CultureInfo.InvariantCulture); +} +``` + +> NOTE: `series` and `shapes` wrappers are intentionally left unhandled in `AddWrapper` here; Tasks 7 (shapes) and 8 (chart) add their handling. `FormatInvariant` is included to keep one invariant-culture helper available; if the analyzer flags it as unused (IDE0051/CA1823), inline-delete it in Task 8 when numeric formatting is actually used, or remove it now — see Step 4. + +Create `src/FlexRender.Xml/XmlTemplateParser.cs`: + +```csharp +using FlexRender.Abstractions; +using FlexRender.Configuration; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; + +namespace FlexRender.Xml; + +/// +/// Parses FlexRender XML templates into the same AST as the YAML parser. +/// XML is translated into the equivalent document tree and handed to the shared +/// so all element parsing, validation, and resource limits are reused. +/// +public sealed class XmlTemplateParser : ITemplateParser +{ + private readonly TemplateParser _inner; + + /// + /// Initializes a new instance of the class with default resource limits. + /// + public XmlTemplateParser() : this(new ResourceLimits()) + { + } + + /// + /// Initializes a new instance of the class with custom resource limits. + /// + /// The resource limits to apply. + /// Thrown when is null. + public XmlTemplateParser(ResourceLimits limits) + { + ArgumentNullException.ThrowIfNull(limits); + _inner = new TemplateParser(limits); + } + + /// + /// Parses an XML template string into a AST. + /// + /// The XML template content. + /// The parsed template. + /// Thrown when is null. + /// Thrown when the XML is malformed or invalid. + public Template Parse(string content) + { + ArgumentNullException.ThrowIfNull(content); + + if (string.IsNullOrWhiteSpace(content)) + { + throw new TemplateParseException("Template XML is empty or whitespace"); + } + + var root = XmlToYamlNodeConverter.Convert(content); + return _inner.ParseDocumentRoot(root); + } + + /// + /// Parses an XML template from a stream. + /// + /// The stream containing XML content. + /// The parsed template. + /// Thrown when is null. + /// Thrown when the XML is malformed or invalid. + public Template Parse(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + using var reader = new StreamReader(stream); + return Parse(reader.ReadToEnd()); + } +} +``` + +- [ ] **Step 4: Remove the unused FormatInvariant helper if the build flags it** + +Run: `dotnet build FlexRender.slnx` +If the build fails on `FormatInvariant` (IDE0051 / CA1823, `TreatWarningsAsErrors`), delete the `FormatInvariant` method and its doc comment from `XmlToYamlNodeConverter.cs`. Re-run until: Build succeeded, 0 errors. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlTemplateParserBasicTests"` +Expected: PASS (6 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Xml/ tests/FlexRender.Tests/Parsing/Xml/XmlTemplateParserBasicTests.cs +git commit --no-gpg-sign -m "feat: XML template parser with canvas, text, separator" +``` + +--- + +### Task 4: Flex nesting and remaining leaf elements (qr/barcode/image/content) + +The converter already recurses into non-wrapper children as `children`. Add tests proving flex nesting and the other leaf elements work through the shared parser. + +**Files:** +- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlFlexNestingTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Xml/XmlFlexNestingTests.cs`: + +```csharp +using FlexRender.Layout; +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for nested flex containers and leaf elements via the XML parser. +/// +public class XmlFlexNestingTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_NestedFlexWithChildren() + { + const string xml = """ + + + + + + + + + + + """; + + var flex = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(FlexDirection.Row, flex.Direction); + Assert.Equal(JustifyContent.Center, flex.Justify); + Assert.Equal(2, flex.Children.Count); + + var inner = Assert.IsType(flex.Children[1]); + Assert.Equal(FlexDirection.Column, inner.Direction); + Assert.Equal(2, inner.Children.Count); + Assert.Equal("A", Assert.IsType(inner.Children[0]).Content); + } + + [Fact] + public void Parse_QrBarcodeImage() + { + const string xml = """ + + + + + + + """; + + var elements = _parser.Parse(xml).Elements; + var qr = Assert.IsType(elements[0]); + Assert.Equal("hello", qr.Data); + Assert.Equal(ErrorCorrectionLevel.H, qr.ErrorCorrection); + + var barcode = Assert.IsType(elements[1]); + Assert.Equal(BarcodeFormat.Ean13, barcode.Format); + + var image = Assert.IsType(elements[2]); + Assert.Equal("logo.png", image.Src); + Assert.Equal(ImageFit.Cover, image.Fit); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails or passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlFlexNestingTests"` +Expected: PASS — the converter already handles nested children and attribute mapping. If any assertion fails, fix `ConvertElement`/`NaturalListKey` until green. (This task is primarily a coverage lock for the generic recursion.) + +- [ ] **Step 3: Commit** + +```bash +git add tests/FlexRender.Tests/Parsing/Xml/XmlFlexNestingTests.cs +git commit --no-gpg-sign -m "test: XML flex nesting and qr/barcode/image elements" +``` + +--- + +### Task 5: Control flow — each / if (then/else/else-if) + +**Files:** +- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs`: + +```csharp +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for each/if control-flow elements via the XML parser. +/// +public class XmlControlFlowTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_Each_WithChildren() + { + const string xml = """ + + + + + + + """; + + var each = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal("items", each.ArrayPath); + Assert.Equal("item", each.ItemVariable); + Assert.Single(each.ItemTemplate); + Assert.IsType(each.ItemTemplate[0]); + } + + [Fact] + public void Parse_If_ThenElse() + { + const string xml = """ + + + + + + + + + + + + """; + + var ifEl = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal("paid", ifEl.ConditionPath); + Assert.Equal(ConditionOperator.Equals, ifEl.Operator); + Assert.Equal("true", ifEl.CompareValue); + Assert.Single(ifEl.ThenBranch); + Assert.Single(ifEl.ElseBranch); + Assert.Equal("PAID", Assert.IsType(ifEl.ThenBranch[0]).Content); + Assert.Equal("DUE", Assert.IsType(ifEl.ElseBranch[0]).Content); + } + + [Fact] + public void Parse_If_ElseIf() + { + const string xml = """ + + + + + + + + + + + + """; + + var ifEl = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.NotNull(ifEl.ElseIf); + Assert.Equal("warm", ifEl.ElseIf!.CompareValue); + } +} +``` + +> Verify property names against `IfElement`/`EachElement` (`ConditionPath`, `Operator`, `CompareValue`, `ThenBranch`, `ElseBranch`, `ElseIf`, `ItemTemplate`, `ArrayPath`, `ItemVariable`). If a name differs, adjust the test assertion to match the actual AST property. + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlControlFlowTests"` +Expected: PASS — `then`/`else`/`else-if` wrappers are already handled in `AddWrapper`, and `each` children fall through to `children`. If `else-if` fails, confirm `AddWrapper` adds key `elseIf` (the YAML parser reads `elseIf`). + +- [ ] **Step 3: Commit** + +```bash +git add tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs +git commit --no-gpg-sign -m "test: XML each/if control-flow parsing" +``` + +--- + +### Task 6: Table (columns / rows) + +**Files:** +- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlTableTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Xml/XmlTableTests.cs`: + +```csharp +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for the table element via the XML parser. +/// +public class XmlTableTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_DynamicTable_Columns() + { + const string xml = """ + + +
+ + + + +
+
+ """; + + var table = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal("lines", table.ArrayPath); + Assert.Equal(2, table.Columns.Count); + Assert.Equal("name", table.Columns[0].Key); + Assert.Equal("Item", table.Columns[0].Label); + Assert.Equal(TextAlign.Right, table.Columns[1].Align); + } + + [Fact] + public void Parse_StaticTable_Rows() + { + const string xml = """ + + + + + + + + + + + +
+
+ """; + + var table = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(2, table.Rows.Count); + Assert.Equal("Coffee", table.Rows[0].Values["name"]); + Assert.Equal("1", table.Rows[1].Values["qty"]); + } +} +``` + +> Confirm `TableElement` exposes `Columns`, `Rows`, `ArrayPath`, and `TableRow.Values` (a dictionary). Adjust assertions if the AST differs. + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlTableTests"` +Expected: PASS — `columns`/`rows` wrappers map to attribute-item sequences; the shared `ParseTableElement` builds the rest. + +- [ ] **Step 3: Commit** + +```bash +git add tests/FlexRender.Tests/Parsing/Xml/XmlTableTests.cs +git commit --no-gpg-sign -m "test: XML table columns/rows parsing" +``` + +--- + +### Task 7: Shapes — rect / circle / ellipse / draw (shapes wrapper) + +The `draw` element's `shapes` wrapper holds one-shape-per-child elements. The YAML `draw` parser expects `shapes: [ { line: {...} }, { rect: {...} } ]` — each shape item is a mapping with a single shape-kind key whose value is the shape's attribute mapping. Implement that translation. + +**Files:** +- Modify: `src/FlexRender.Xml/XmlToYamlNodeConverter.cs` +- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlShapeTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Xml/XmlShapeTests.cs`: + +```csharp +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for shape elements (rect, circle, ellipse, draw) via the XML parser. +/// +public class XmlShapeTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_RectCircleEllipse() + { + const string xml = """ + + + + + + + """; + + var elements = _parser.Parse(xml).Elements; + var rect = Assert.IsType(elements[0]); + Assert.Equal("#4A90D9", rect.Fill.Value); + Assert.Equal(2f, rect.StrokeWidth.Value); + + Assert.IsType(elements[1]); + Assert.IsType(elements[2]); + } + + [Fact] + public void Parse_Draw_WithShapes() + { + const string xml = """ + + + + + + + + + + + + """; + + var draw = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(4, draw.Shapes.Count); + Assert.IsType(draw.Shapes[0]); + Assert.IsType(draw.Shapes[1]); + Assert.IsType(draw.Shapes[2]); + Assert.IsType(draw.Shapes[3]); + } +} +``` + +> Confirm `DrawElement.Shapes` and the concrete shape types (`DrawLine`, `DrawRect`, `DrawCircle`, `DrawPolyline`, `DrawPath`) — they live in `src/FlexRender.Core/Parsing/Ast/DrawShapes.cs`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlShapeTests"` +Expected: rect/circle/ellipse PASS; `Parse_Draw_WithShapes` FAILS — `shapes` wrapper is not yet translated (draw has 0 shapes). + +- [ ] **Step 3: Implement the shapes wrapper translation** + +In `src/FlexRender.Xml/XmlToYamlNodeConverter.cs`, in `AddWrapper`, replace the `case "shapes":` branch (currently a no-op comment) with: + +```csharp + case "shapes": + node.Add("shapes", ConvertShapeSequence(wrapper)); + break; +``` + +Then add this method to the class: + +```csharp + /// + /// Converts a <shapes> wrapper into the YAML shapes sequence, where each shape + /// becomes a mapping with a single shape-kind key (line/polyline/rect/circle/path) whose value + /// is the shape's attribute mapping (with any list attributes such as points expanded). + /// + private static YamlSequenceNode ConvertShapeSequence(XElement wrapper) + { + var seq = new YamlSequenceNode(); + foreach (var shape in wrapper.Elements()) + { + var shapeMapping = new YamlMappingNode(); + foreach (var attr in shape.Attributes()) + { + var name = attr.Name.LocalName; + shapeMapping.Add( + name, + ListAttributes.Contains(name) + ? ExpandListAttribute(attr.Value) + : new YamlScalarNode(attr.Value)); + } + + var wrapped = new YamlMappingNode(); + wrapped.Add(shape.Name.LocalName, shapeMapping); + seq.Add(wrapped); + } + return seq; + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlShapeTests"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Xml/XmlToYamlNodeConverter.cs tests/FlexRender.Tests/Parsing/Xml/XmlShapeTests.cs +git commit --no-gpg-sign -m "feat: XML draw shapes wrapper translation" +``` + +--- + +### Task 8: Chart — series / categories / palette / tuple data + +The YAML chart parser expects `series: [ { label, data: [...] } ]`. The XML form uses repeated `` children of `` (NOT wrapped). These are non-wrapper children, so the generic recursion would currently push them under `children`. Add explicit handling: `series` children collect into a YAML `series` sequence of attribute mappings (with `data`/`points` expanded), and they must NOT recurse as layout elements. + +**Files:** +- Modify: `src/FlexRender.Xml/XmlToYamlNodeConverter.cs` +- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlChartTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Xml/XmlChartTests.cs`: + +```csharp +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for the chart element via the XML parser. +/// +public class XmlChartTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_BarChart_SeriesAndCategories() + { + const string xml = """ + + + + + Q1Q2Q3Q4 + + + + + + """; + + var chart = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(ChartType.Bar, chart.ChartType); + Assert.Equal("Sales", chart.Title); + Assert.Equal(new[] { "Q1", "Q2", "Q3", "Q4" }, chart.Categories); + Assert.Equal(2, chart.Series.Count); + Assert.Equal("2024", chart.Series[0].Label); + Assert.Equal(new[] { 12d, 30d, 22d, 48d }, chart.Series[0].Data); + } + + [Fact] + public void Parse_NamedPalette_Attribute() + { + const string xml = """ + + + + + + + """; + + var chart = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.NotNull(chart.Palette); + } + + [Fact] + public void Parse_ScatterTupleData() + { + const string xml = """ + + + + + + + """; + + var chart = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + var series = Assert.Single(chart.Series); + Assert.Equal(3, series.Points.Count); + Assert.Equal(3d, series.Points[1].X); + Assert.Equal(4d, series.Points[1].Y); + } +} +``` + +> The YAML series parser reads tuple data from the `data` key (an array-of-arrays). For scatter/bubble XML we map the `points` attribute on `` to the YAML `data` key. Confirm `ChartPoint.X/Y` and `ChartSeries.Points`/`.Data` names against the AST. Also confirm `ChartElement.ChartType`, `.Categories`, `.Series`, `.Title`, `.Palette`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlChartTests"` +Expected: FAIL — `` children are not collected into the YAML `series` key (chart has 0 series / wrong shape). + +- [ ] **Step 3: Implement chart series collection** + +In `src/FlexRender.Xml/XmlToYamlNodeConverter.cs`: + +First, prevent `` from being treated as a generic layout child. In `ConvertElement`, change the child loop so series elements are collected separately. Replace the child-element loop body in `ConvertElement` (the `foreach (var child in el.Elements())` block) with: + +```csharp + // Child elements. + var naturalList = new YamlSequenceNode(); + var seriesList = new YamlSequenceNode(); + foreach (var child in el.Elements()) + { + var childName = child.Name.LocalName; + if (string.Equals(childName, "series", StringComparison.Ordinal)) + { + seriesList.Add(ConvertSeries(child)); + } + else if (WrapperNames.Contains(childName)) + { + AddWrapper(node, type, child); + } + else + { + naturalList.Add(ConvertElement(child)); + } + } + + if (seriesList.Children.Count > 0) + { + node.Add("series", seriesList); + } + + if (naturalList.Children.Count > 0) + { + node.Add(NaturalListKey(type), naturalList); + } + + return node; +``` + +Then add the `ConvertSeries` method. It maps a `` element's attributes to a series mapping, translating the XML `points` attribute (tuple data) into the YAML `data` array-of-arrays the chart parser expects, and expanding the flat `data` attribute into a numeric array: + +```csharp + /// + /// Converts a <series> element into a YAML series mapping. The XML data attribute + /// (comma-separated numbers or a {{expression}}) maps to the YAML data key; + /// the XML points attribute (semicolon-separated x,y[,r] tuples for scatter/bubble) + /// also maps to data as an array-of-arrays. + /// + private static YamlMappingNode ConvertSeries(XElement series) + { + var node = new YamlMappingNode(); + foreach (var attr in series.Attributes()) + { + var name = attr.Name.LocalName; + switch (name) + { + case "data": + node.Add("data", ExpandListAttribute(attr.Value)); + break; + case "points": + node.Add("data", ExpandListAttribute(attr.Value)); + break; + default: + node.Add(name, new YamlScalarNode(attr.Value)); + break; + } + } + return node; + } +``` + +(`series` is already in `WrapperNames`; leaving it there is harmless because the new explicit branch intercepts it first. To avoid confusion, remove `"series"` from the `WrapperNames` set and remove the now-dead `case "series":` no-op branch in `AddWrapper`.) + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlChartTests"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Build to confirm no dead-code analyzer errors** + +Run: `dotnet build FlexRender.slnx` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Xml/XmlToYamlNodeConverter.cs tests/FlexRender.Tests/Parsing/Xml/XmlChartTests.cs +git commit --no-gpg-sign -m "feat: XML chart series, categories, palette, tuple data" +``` + +--- + +### Task 9: KnownProperties validation and typo suggestions flow through XML + +Because the XML parser reuses `TemplateParser.ParseElement` → `KnownProperties.Validate`, unknown attributes already raise the same `TemplateParseException` with typo suggestions. Lock this with tests. + +**Files:** +- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlValidationTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Xml/XmlValidationTests.cs`: + +```csharp +using FlexRender.Parsing; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests that XML attribute validation reuses the YAML KnownProperties machinery. +/// +public class XmlValidationTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_UnknownAttribute_ThrowsWithSuggestion() + { + const string xml = """ + + + + + """; + + var ex = Assert.Throws(() => _parser.Parse(xml)); + Assert.Contains("colour", ex.Message, System.StringComparison.Ordinal); + Assert.Contains("color", ex.Message, System.StringComparison.Ordinal); + } + + [Fact] + public void Parse_UnknownElementType_Throws() + { + const string xml = """ + + + + + """; + + var ex = Assert.Throws(() => _parser.Parse(xml)); + Assert.Contains("Unknown element type", ex.Message, System.StringComparison.Ordinal); + } + + [Fact] + public void Parse_CaseMismatchAttribute_HintsCaseSensitivity() + { + const string xml = """ + + + + + """; + + var ex = Assert.Throws(() => _parser.Parse(xml)); + Assert.Contains("case-sensitive", ex.Message, System.StringComparison.Ordinal); + } +} +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlValidationTests"` +Expected: PASS (3 tests) — validation is inherited from the shared parser. If `Parse_UnknownElementType_Throws` fails because the unknown `` was silently swallowed, confirm it reaches `ParseElement` (it should, as a layout child). + +- [ ] **Step 3: Commit** + +```bash +git add tests/FlexRender.Tests/Parsing/Xml/XmlValidationTests.cs +git commit --no-gpg-sign -m "test: XML unknown attribute/element validation and typo hints" +``` + +--- + +### Task 10: Resource limits enforced through the XML parser + +The shared `TemplateParser` is constructed with the `ResourceLimits` passed to `XmlTemplateParser`, so `MaxShapesPerDraw`, `MaxSeriesPerChart`, and `MaxDataPointsPerSeries` are enforced. Lock this. + +**Files:** +- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlResourceLimitTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Xml/XmlResourceLimitTests.cs`: + +```csharp +using System.Text; +using FlexRender.Configuration; +using FlexRender.Parsing; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests that ResourceLimits are enforced by the XML parser via the shared TemplateParser. +/// +public class XmlResourceLimitTests +{ + [Fact] + public void Parse_TooManyShapes_Throws() + { + var limits = new ResourceLimits { MaxShapesPerDraw = 2 }; + var parser = new XmlTemplateParser(limits); + + var sb = new StringBuilder(); + sb.Append(""); + for (var i = 0; i < 3; i++) + { + sb.Append(""); + } + sb.Append(""); + + var ex = Assert.Throws(() => parser.Parse(sb.ToString())); + Assert.Contains("exceeds the maximum", ex.Message, System.StringComparison.Ordinal); + } + + [Fact] + public void Parse_TooManySeries_Throws() + { + var limits = new ResourceLimits { MaxSeriesPerChart = 1 }; + var parser = new XmlTemplateParser(limits); + + const string xml = """ + + + + + + + + """; + + var ex = Assert.Throws(() => parser.Parse(xml)); + Assert.Contains("exceeds the maximum", ex.Message, System.StringComparison.Ordinal); + } +} +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlResourceLimitTests"` +Expected: PASS (2 tests). + +- [ ] **Step 3: Commit** + +```bash +git add tests/FlexRender.Tests/Parsing/Xml/XmlResourceLimitTests.cs +git commit --no-gpg-sign -m "test: XML parser enforces resource limits" +``` + +--- + +### Task 11: Cross-check — XML and YAML produce equivalent ASTs + +Prove a non-trivial template parses identically through both parsers by comparing key AST properties (we compare structurally rather than by reference). + +**Files:** +- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlYamlEquivalenceTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Xml/XmlYamlEquivalenceTests.cs`: + +```csharp +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Cross-checks that XML and YAML parsers produce equivalent ASTs for the same template. +/// +public class XmlYamlEquivalenceTests +{ + private const string Yaml = """ + canvas: + width: 400 + layout: + - type: flex + direction: row + gap: 8 + children: + - type: text + content: "Quarterly sales" + size: 1.2em + - type: chart + chart-type: bar + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: [12, 30, 22, 48] + - label: "2025" + data: [18, 26, 31, 40] + """; + + private const string Xml = """ + + + + Quarterly sales + + + Q1Q2Q3Q4 + + + + + + + """; + + [Fact] + public void XmlAndYaml_ProduceEquivalentAst() + { + var fromYaml = new TemplateParser().Parse(Yaml); + var fromXml = new XmlTemplateParser().Parse(Xml); + + Assert.Equal(fromYaml.Canvas.Width, fromXml.Canvas.Width); + + var yamlFlex = Assert.IsType(Assert.Single(fromYaml.Elements)); + var xmlFlex = Assert.IsType(Assert.Single(fromXml.Elements)); + Assert.Equal(yamlFlex.Direction, xmlFlex.Direction); + Assert.Equal(yamlFlex.Children.Count, xmlFlex.Children.Count); + + var yamlText = Assert.IsType(yamlFlex.Children[0]); + var xmlText = Assert.IsType(xmlFlex.Children[0]); + Assert.Equal(yamlText.Content, xmlText.Content); + Assert.Equal(yamlText.Size.Value, xmlText.Size.Value); + + var yamlChart = Assert.IsType(yamlFlex.Children[1]); + var xmlChart = Assert.IsType(xmlFlex.Children[1]); + Assert.Equal(yamlChart.ChartType, xmlChart.ChartType); + Assert.Equal(yamlChart.Categories, xmlChart.Categories); + Assert.Equal(yamlChart.Series.Count, xmlChart.Series.Count); + Assert.Equal(yamlChart.Series[0].Label, xmlChart.Series[0].Label); + Assert.Equal(yamlChart.Series[0].Data, xmlChart.Series[0].Data); + Assert.Equal(yamlChart.Series[1].Data, xmlChart.Series[1].Data); + } +} +``` + +- [ ] **Step 2: Run the test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlYamlEquivalenceTests"` +Expected: PASS (1 test). If `gap`/direction differ, recheck the converter attribute pass-through. + +- [ ] **Step 3: Commit** + +```bash +git add tests/FlexRender.Tests/Parsing/Xml/XmlYamlEquivalenceTests.cs +git commit --no-gpg-sign -m "test: XML/YAML AST equivalence cross-check" +``` + +--- + +### Task 12: RenderXml extension methods + +Mirror the `RenderYaml` string-based extensions so consumers can render XML directly. These live in `FlexRender.Xml`. They delegate to the same `IFlexRender.Render(Template)` path used by `RenderYaml`. Keep the surface minimal (YAGNI): one `RenderXml(this IFlexRender, string xml, object? data)` returning `byte[]`, matching the primary `RenderYaml` overload. + +**Files:** +- Create: `src/FlexRender.Xml/XmlFlexRenderExtensions.cs` +- Test: `tests/FlexRender.Tests/Parsing/Xml/XmlRenderExtensionTests.cs` + +- [ ] **Step 1: Inspect the canonical RenderYaml overload to mirror its exact signature** + +Read `src/FlexRender.Yaml/FlexRenderExtensions.cs` lines 53-80 (the primary `RenderYaml(this IFlexRender render, string yaml, object? data = null, ... TemplateParser? parser = null, CancellationToken ...)` overload). Mirror its body but: build the `Template` via `XmlToYamlNodeConverter` + the internal `ParseDocumentRoot`, or simply via `new XmlTemplateParser().Parse(xml)`. Use `XmlTemplateParser` to avoid touching internals from the extension. Confirm the downstream call it makes (e.g. `render.RenderToBytes(template, data, ...)` or an internal helper) and reuse the identical downstream call. + +- [ ] **Step 2: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Xml/XmlRenderExtensionTests.cs`: + +```csharp +using FlexRender.Configuration; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for the RenderXml extension method. +/// +public class XmlRenderExtensionTests +{ + [Fact] + public async Task RenderXml_ProducesNonEmptyImage() + { + var render = new FlexRenderBuilder() + .WithSkia() + .Build(); + + const string xml = """ + + + + + """; + + var bytes = await render.RenderXml(xml); + + Assert.NotNull(bytes); + Assert.NotEmpty(bytes); + } +} +``` + +> Confirm `FlexRenderBuilder().WithSkia().Build()` is the correct construction used elsewhere in tests (grep existing tests for `WithSkia()`); adjust if the test project uses a different builder entry point. + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlRenderExtensionTests"` +Expected: FAIL — `RenderXml` does not exist. + +- [ ] **Step 4: Implement the extension** + +Create `src/FlexRender.Xml/XmlFlexRenderExtensions.cs`. Mirror the exact body of the primary `RenderYaml` overload from Step 1, substituting XML parsing. Template (adjust the downstream render call to match what `RenderYaml` actually calls): + +```csharp +using FlexRender.Abstractions; +using FlexRender.Parsing; + +namespace FlexRender.Xml; + +/// +/// Convenience extension methods for rendering FlexRender XML templates directly. +/// +public static class XmlFlexRenderExtensions +{ + /// + /// Parses an XML template and renders it to image bytes. + /// + /// The render instance. + /// The XML template content. + /// Optional data context for expression resolution. + /// Optional XML parser instance; a default one is created when null. + /// Cancellation token. + /// The rendered image bytes. + /// Thrown when or is null. + /// Thrown when the XML is malformed or invalid. + public static async Task RenderXml( + this IFlexRender render, + string xml, + object? data = null, + XmlTemplateParser? parser = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(render); + ArgumentNullException.ThrowIfNull(xml); + + parser ??= new XmlTemplateParser(); + var template = parser.Parse(xml); + + // Mirror the downstream call performed by RenderYaml. Replace the body below with the + // identical call RenderYaml makes (e.g. render.RenderAsync(template, data, cancellationToken)). + return await render.RenderAsync(template, data, cancellationToken).ConfigureAwait(false); + } +} +``` + +> CRITICAL: the final `return await render.<...>` MUST be the exact downstream call `RenderYaml` uses (method name + argument order). Copy it verbatim from `FlexRenderExtensions.RenderYaml` (Step 1). Do not invent a method name. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~XmlRenderExtensionTests"` +Expected: PASS (1 test). + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Xml/XmlFlexRenderExtensions.cs tests/FlexRender.Tests/Parsing/Xml/XmlRenderExtensionTests.cs +git commit --no-gpg-sign -m "feat: RenderXml extension method" +``` + +--- + +### Task 13: Add FlexRender.Xml to the meta-package + +**Files:** +- Modify: `src/FlexRender.MetaPackage/FlexRender.MetaPackage.csproj` + +- [ ] **Step 1: Add the project reference** + +In `src/FlexRender.MetaPackage/FlexRender.MetaPackage.csproj`, add inside the existing `` of `ProjectReference`s, directly after the `FlexRender.Yaml` reference: + +```xml + +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build FlexRender.slnx` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/FlexRender.MetaPackage/FlexRender.MetaPackage.csproj +git commit --no-gpg-sign -m "build: include FlexRender.Xml in meta-package" +``` + +--- + +### Task 14: Documentation — wiki page, AGENTS.md, llms.txt + +**Files:** +- Create: `docs/wiki/Xml-Syntax.md` +- Modify: `AGENTS.md` +- Modify: `llms.txt` +- Modify: `llms-full.txt` + +- [ ] **Step 1: Create the wiki page** + +Create `docs/wiki/Xml-Syntax.md` documenting the mapping rules and the full side-by-side example from the "XML Syntax Mapping" section of this plan. Include: document shape table, element/attribute rules, the text-inner-text rule, wrapper rules (`then`/`else`/`else-if`/`columns`/`rows`/`series`/`categories`/`palette`/`shapes`), list attribute rules (`data`/`points`), and the flex+chart YAML-vs-XML example. Reuse the exact tables and code blocks from this plan's mapping section verbatim so docs and behaviour stay in lockstep. + +- [ ] **Step 2: Update AGENTS.md package structure** + +In `AGENTS.md`, locate the package-structure section (the tree that lists `FlexRender.Yaml`). Add a sibling bullet/line for the new parser package immediately under the `FlexRender.Yaml` entry: + +``` +├── FlexRender.Xml — alternative XML template parser (same Template AST as YAML); RenderXml extension +``` + +- [ ] **Step 3: Note XML as an alternative input in llms.txt and llms-full.txt** + +In `llms.txt`, add a short line in the parsers/usage section: "Templates may be authored in YAML (`FlexRender.Yaml`) or XML (`FlexRender.Xml`, via `XmlTemplateParser` / `RenderXml`). Both produce the same AST." In `llms-full.txt`, add the same note plus the minimal XML example: + +```xml + + + + +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/wiki/Xml-Syntax.md AGENTS.md llms.txt llms-full.txt +git commit --no-gpg-sign -m "docs: document XML template syntax" +``` + +> If `llms.txt` / `llms-full.txt` do not exist at the repo root, search for them (`git ls-files | grep -i llms`) and edit the actual files; if absent, skip those two and note it in the commit body. + +--- + +### Task 15: Final verification — full build and test suite + +**Files:** none + +- [ ] **Step 1: Full solution build** + +Run: `dotnet build FlexRender.slnx` +Expected: Build succeeded, 0 errors, 0 warnings (warnings are errors here). + +- [ ] **Step 2: Full XML test suite** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~Parsing.Xml"` +Expected: PASS — all XML parser tests green. + +- [ ] **Step 3: Full regression suite (net10.0)** + +Run: `dotnet test FlexRender.slnx --framework net10.0` +Expected: PASS — entire suite green (the YAML refactor in Task 2 changed no behaviour). + +- [ ] **Step 4: Commit any final cleanups (if needed)** + +```bash +git add -A +git commit --no-gpg-sign -m "chore: finalize FlexRender.Xml parser phase 5" +``` + +--- + +## Self-Review + +**1. Spec coverage (Phase 5: same AST, same elements, parser-level only):** +- New `FlexRender.Xml` package + `ITemplateParser` → Tasks 1, 3. +- Same `Template` AST via shared `TemplateParser.ParseDocumentRoot` → Task 2, proven in Task 11. +- All element types covered: text/separator (T3), flex/qr/barcode/image/content (T4), each/if (T5), table (T6), rect/circle/ellipse/draw + svg/content (T3/T7; svg & content use the generic attribute+inner-text path), chart (T8). +- KnownProperties validation + typo suggestions reused → T9. +- ResourceLimits reused → T10. +- Malformed XML → `TemplateParseException` → T3. +- RenderXml extension + meta-package + DI surface → T12, T13. +- Docs (wiki, AGENTS.md, llms.txt) → T14. +- No new elements, no renderer changes — confirmed: only converter + public parser + extension + docs are new; all element/chart/shape logic is reused. + +**2. Placeholder scan:** No "TBD"/"implement later". Two deferred-but-specified items are explicit: the `series`/`shapes` wrapper branches are introduced as no-ops in T3 and filled in T7/T8 with exact code; the `RenderXml` downstream call in T12 is flagged to be copied verbatim from `RenderYaml` (Step 1 reads the exact line) rather than invented — this is the one place the implementer MUST read the canonical signature, because the YAML extension's downstream method name was not load-bearing-verified here. + +**3. Type consistency:** `XmlToYamlNodeConverter.Convert(string) -> YamlMappingNode`; `XmlTemplateParser.Parse` calls `_inner.ParseDocumentRoot(root)` (matches the internal method added in T2). `ParseDocumentRoot` is `internal` on `TemplateParser`; `InternalsVisibleTo("FlexRender.Xml")` + `("FlexRender.Tests")` added in T2. `ExpandListAttribute`, `ConvertSeries`, `ConvertShapeSequence`, `AddWrapper`, `WrapperNames`, `ListAttributes` are referenced consistently. AST property names used in tests (`ChartType`, `Categories`, `Series[].Label/.Data/.Points`, `ChartPoint.X/Y`, `DrawElement.Shapes`, `TableElement.Columns/Rows`, `IfElement.ThenBranch/ElseBranch/ElseIf/ConditionPath/Operator/CompareValue`, `EachElement.ItemTemplate/ArrayPath/ItemVariable`) are flagged in each task to be verified against the actual AST and adjusted if they differ. + +**Highest-risk tasks:** +- **Task 2** (shared entry point + `InternalsVisibleTo`): refactors the existing YAML `Parse(string)`. Mitigated by running the full YAML parser suite in Step 4. This is the single load-bearing reuse decision. +- **Task 8** (chart series / scatter tuple mapping): the XML `` is a non-wrapper child needing special interception, and the `points` attribute must map to the YAML `data` array-of-arrays the chart parser expects. Confirm `ChartSeries.Points` vs `.Data` semantics for scatter/bubble against `ChartParsers.ParseOneSeries`. +- **Task 12** (`RenderXml` downstream call): must copy the exact render method `RenderYaml` invokes; the YAML extension's downstream signature was not verified in this plan and must be read before implementing. From 82c988dd3e752a0f5023b7ccaf8383b4ef115321 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:06:06 +0400 Subject: [PATCH 093/119] build: scaffold FlexRender.Xml project --- FlexRender.slnx | 1 + src/FlexRender.Xml/FlexRender.Xml.csproj | 13 +++++++++++++ src/FlexRender.Xml/Placeholder.cs | 8 ++++++++ 3 files changed, 22 insertions(+) create mode 100644 src/FlexRender.Xml/FlexRender.Xml.csproj create mode 100644 src/FlexRender.Xml/Placeholder.cs diff --git a/FlexRender.slnx b/FlexRender.slnx index 75fba12..7734293 100644 --- a/FlexRender.slnx +++ b/FlexRender.slnx @@ -16,6 +16,7 @@ + diff --git a/src/FlexRender.Xml/FlexRender.Xml.csproj b/src/FlexRender.Xml/FlexRender.Xml.csproj new file mode 100644 index 0000000..0fcad02 --- /dev/null +++ b/src/FlexRender.Xml/FlexRender.Xml.csproj @@ -0,0 +1,13 @@ + + + + FlexRender.Xml + XML template parsing for FlexRender (alternative to YAML). + true + + + + + + + diff --git a/src/FlexRender.Xml/Placeholder.cs b/src/FlexRender.Xml/Placeholder.cs new file mode 100644 index 0000000..f907aa9 --- /dev/null +++ b/src/FlexRender.Xml/Placeholder.cs @@ -0,0 +1,8 @@ +namespace FlexRender.Xml; + +/// +/// Temporary placeholder so the empty project compiles. Removed in a later task. +/// +internal static class Placeholder +{ +} From eaa8a8b3a224a706202e38806124564abfdc5033 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:07:13 +0400 Subject: [PATCH 094/119] refactor: extract ParseDocumentRoot entry point for parser reuse --- src/FlexRender.Yaml/AssemblyInfo.cs | 4 ++ src/FlexRender.Yaml/Parsing/TemplateParser.cs | 23 ++++++++++ .../Parsing/Xml/InternalEntryPointTests.cs | 42 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 src/FlexRender.Yaml/AssemblyInfo.cs create mode 100644 tests/FlexRender.Tests/Parsing/Xml/InternalEntryPointTests.cs diff --git a/src/FlexRender.Yaml/AssemblyInfo.cs b/src/FlexRender.Yaml/AssemblyInfo.cs new file mode 100644 index 0000000..30bf3dd --- /dev/null +++ b/src/FlexRender.Yaml/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("FlexRender.Xml")] +[assembly: InternalsVisibleTo("FlexRender.Tests")] diff --git a/src/FlexRender.Yaml/Parsing/TemplateParser.cs b/src/FlexRender.Yaml/Parsing/TemplateParser.cs index f7ca03a..b269dd4 100644 --- a/src/FlexRender.Yaml/Parsing/TemplateParser.cs +++ b/src/FlexRender.Yaml/Parsing/TemplateParser.cs @@ -117,6 +117,22 @@ public Template Parse(string content) throw new TemplateParseException($"Invalid YAML: {ex.Message}", ex); } + return ParseDocumentRoot(root); + } + + /// + /// Builds a from an already-parsed YAML document root. + /// Shared by the YAML string/stream entry points and by the XML parser, which + /// translates XML into the equivalent tree. + /// + /// The document root mapping node. + /// The parsed template. + /// Thrown when is null. + /// Thrown when required sections are missing or invalid. + internal Template ParseDocumentRoot(YamlMappingNode root) + { + ArgumentNullException.ThrowIfNull(root); + var template = new Template(); // Parse template metadata @@ -157,6 +173,13 @@ public Template Parse(string content) return template; } + /// + /// Test-only shim exposing to the test assembly. + /// + /// The document root mapping node. + /// The parsed template. + internal Template ParseDocumentRootForTests(YamlMappingNode root) => ParseDocumentRoot(root); + /// /// Parses a YAML template from a stream. /// diff --git a/tests/FlexRender.Tests/Parsing/Xml/InternalEntryPointTests.cs b/tests/FlexRender.Tests/Parsing/Xml/InternalEntryPointTests.cs new file mode 100644 index 0000000..950b141 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Xml/InternalEntryPointTests.cs @@ -0,0 +1,42 @@ +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using YamlDotNet.RepresentationModel; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for the internal entry point +/// used by the XML parser to reuse YAML element parsing. +/// +public class InternalEntryPointTests +{ + /// + /// Verifies that a programmatically built YamlMappingNode root produces the same AST + /// as parsing the equivalent YAML string. + /// + [Fact] + public void ParseDocumentRoot_BuiltNode_ProducesEquivalentAst() + { + // Build: { canvas: { width: 300 }, layout: [ { type: text, content: Hi } ] } + var canvas = new YamlMappingNode(); + canvas.Add("width", "300"); + + var textNode = new YamlMappingNode(); + textNode.Add("type", "text"); + textNode.Add("content", "Hi"); + + var layout = new YamlSequenceNode(); + layout.Add(textNode); + + var root = new YamlMappingNode(); + root.Add("canvas", canvas); + root.Add("layout", layout); + + var template = new TemplateParser().ParseDocumentRootForTests(root); + + var text = Assert.IsType(Assert.Single(template.Elements)); + Assert.Equal("Hi", text.Content); + Assert.Equal(300, template.Canvas.Width); + } +} From a0d2ba0d7ef243fb065f68b87a995d42459c363e Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:10:00 +0400 Subject: [PATCH 095/119] feat: XML template parser with canvas, text, separator --- src/FlexRender.Xml/Placeholder.cs | 8 - src/FlexRender.Xml/XmlTemplateParser.cs | 68 ++++ src/FlexRender.Xml/XmlToYamlNodeConverter.cs | 307 ++++++++++++++++++ .../FlexRender.Tests/FlexRender.Tests.csproj | 1 + .../Xml/XmlTemplateParserBasicTests.cs | 84 +++++ 5 files changed, 460 insertions(+), 8 deletions(-) delete mode 100644 src/FlexRender.Xml/Placeholder.cs create mode 100644 src/FlexRender.Xml/XmlTemplateParser.cs create mode 100644 src/FlexRender.Xml/XmlToYamlNodeConverter.cs create mode 100644 tests/FlexRender.Tests/Parsing/Xml/XmlTemplateParserBasicTests.cs diff --git a/src/FlexRender.Xml/Placeholder.cs b/src/FlexRender.Xml/Placeholder.cs deleted file mode 100644 index f907aa9..0000000 --- a/src/FlexRender.Xml/Placeholder.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace FlexRender.Xml; - -/// -/// Temporary placeholder so the empty project compiles. Removed in a later task. -/// -internal static class Placeholder -{ -} diff --git a/src/FlexRender.Xml/XmlTemplateParser.cs b/src/FlexRender.Xml/XmlTemplateParser.cs new file mode 100644 index 0000000..0e870a9 --- /dev/null +++ b/src/FlexRender.Xml/XmlTemplateParser.cs @@ -0,0 +1,68 @@ +using FlexRender.Abstractions; +using FlexRender.Configuration; +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; + +namespace FlexRender.Xml; + +/// +/// Parses FlexRender XML templates into the same AST as the YAML parser. +/// XML is translated into the equivalent document tree and handed to the shared +/// so all element parsing, validation, and resource limits are reused. +/// +public sealed class XmlTemplateParser : ITemplateParser +{ + private readonly TemplateParser _inner; + + /// + /// Initializes a new instance of the class with default resource limits. + /// + public XmlTemplateParser() : this(new ResourceLimits()) + { + } + + /// + /// Initializes a new instance of the class with custom resource limits. + /// + /// The resource limits to apply. + /// Thrown when is null. + public XmlTemplateParser(ResourceLimits limits) + { + ArgumentNullException.ThrowIfNull(limits); + _inner = new TemplateParser(limits); + } + + /// + /// Parses an XML template string into a AST. + /// + /// The XML template content. + /// The parsed template. + /// Thrown when is null. + /// Thrown when the XML is malformed or invalid. + public Template Parse(string content) + { + ArgumentNullException.ThrowIfNull(content); + + if (string.IsNullOrWhiteSpace(content)) + { + throw new TemplateParseException("Template XML is empty or whitespace"); + } + + var root = XmlToYamlNodeConverter.Convert(content); + return _inner.ParseDocumentRoot(root); + } + + /// + /// Parses an XML template from a stream. + /// + /// The stream containing XML content. + /// The parsed template. + /// Thrown when is null. + /// Thrown when the XML is malformed or invalid. + public Template Parse(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + using var reader = new StreamReader(stream); + return Parse(reader.ReadToEnd()); + } +} diff --git a/src/FlexRender.Xml/XmlToYamlNodeConverter.cs b/src/FlexRender.Xml/XmlToYamlNodeConverter.cs new file mode 100644 index 0000000..5c2388e --- /dev/null +++ b/src/FlexRender.Xml/XmlToYamlNodeConverter.cs @@ -0,0 +1,307 @@ +using System.Xml.Linq; +using FlexRender.Parsing; +using YamlDotNet.RepresentationModel; + +namespace FlexRender.Xml; + +/// +/// Converts a FlexRender XML template tree into the equivalent +/// document root that the YAML +/// consumes, so all element/chart/shape parsing and +/// validation is reused without duplication. +/// +internal static class XmlToYamlNodeConverter +{ + /// The root element local-name. + private const string RootName = "flexrender"; + + /// + /// Wrapper child element names that map to dedicated YAML list/branch keys rather than + /// being treated as nested layout elements. + /// + private static readonly HashSet WrapperNames = new(StringComparer.Ordinal) + { + "then", "else", "else-if", "columns", "rows", "series", + "categories", "x-labels", "y-labels", "palette", "shapes" + }; + + /// + /// Attribute names whose comma/semicolon values expand into YAML sequences. + /// + private static readonly HashSet ListAttributes = new(StringComparer.Ordinal) + { + "data", "points", "categories", "palette", "x-labels", "y-labels" + }; + + /// + /// Parses the XML string and builds the YAML document root mapping. + /// + /// The raw XML template. + /// The equivalent document root mapping node. + /// Thrown when the XML is malformed or the root element is wrong. + internal static YamlMappingNode Convert(string xml) + { + XDocument doc; + try + { + doc = XDocument.Parse(xml, LoadOptions.None); + } + catch (System.Xml.XmlException ex) + { + throw new TemplateParseException($"Invalid XML: {ex.Message}", ex); + } + + var rootEl = doc.Root + ?? throw new TemplateParseException("XML template has no root element."); + + if (!string.Equals(rootEl.Name.LocalName, RootName, StringComparison.Ordinal)) + { + throw new TemplateParseException( + $"XML template root must be <{RootName}>, but was <{rootEl.Name.LocalName}>."); + } + + var root = new YamlMappingNode(); + + // template metadata from root attributes + var metadata = new YamlMappingNode(); + AddAttrIfPresent(rootEl, "name", metadata); + AddAttrIfPresent(rootEl, "version", metadata); + AddAttrIfPresent(rootEl, "culture", metadata); + if (metadata.Children.Count > 0) + { + root.Add("template", metadata); + } + + var layout = new YamlSequenceNode(); + + foreach (var child in rootEl.Elements()) + { + var localName = child.Name.LocalName; + switch (localName) + { + case "canvas": + root.Add("canvas", AttributesToMapping(child)); + break; + case "fonts": + root.Add("fonts", ConvertFonts(child)); + break; + default: + layout.Add(ConvertElement(child)); + break; + } + } + + root.Add("layout", layout); + return root; + } + + /// + /// Builds a mapping node containing only the element's attributes (no type, no children). + /// + private static YamlMappingNode AttributesToMapping(XElement el) + { + var node = new YamlMappingNode(); + foreach (var attr in el.Attributes()) + { + node.Add(attr.Name.LocalName, new YamlScalarNode(attr.Value)); + } + return node; + } + + /// + /// Converts a single layout element (recursively) into a YAML mapping node with a + /// type entry, scalar attributes, and child-derived list properties. + /// + private static YamlMappingNode ConvertElement(XElement el) + { + var type = el.Name.LocalName; + var node = new YamlMappingNode(); + node.Add("type", new YamlScalarNode(type)); + + // Attributes -> scalar or list entries. + foreach (var attr in el.Attributes()) + { + var name = attr.Name.LocalName; + if (ListAttributes.Contains(name)) + { + node.Add(name, ExpandListAttribute(attr.Value)); + } + else + { + node.Add(name, new YamlScalarNode(attr.Value)); + } + } + + // Inner text -> content (only when no content attribute and there are no child elements). + if (el.Attribute("content") is null && !el.HasElements) + { + var inner = el.Value; + if (!string.IsNullOrWhiteSpace(inner)) + { + node.Add("content", new YamlScalarNode(inner.Trim())); + } + } + + // Child elements. + var naturalList = new YamlSequenceNode(); + foreach (var child in el.Elements()) + { + var childName = child.Name.LocalName; + if (WrapperNames.Contains(childName)) + { + AddWrapper(node, type, child); + } + else + { + naturalList.Add(ConvertElement(child)); + } + } + + if (naturalList.Children.Count > 0) + { + node.Add(NaturalListKey(type), naturalList); + } + + return node; + } + + /// + /// Maps an element type to the YAML key its directly-nested layout children belong under. + /// + private static string NaturalListKey(string type) => type switch + { + "each" => "children", + _ => "children" // flex and any other container use 'children' + }; + + /// + /// Adds a recognised wrapper child (then/else/columns/series/...) to the node under its YAML key. + /// + private static void AddWrapper(YamlMappingNode node, string parentType, XElement wrapper) + { + var name = wrapper.Name.LocalName; + switch (name) + { + case "then": + case "else": + node.Add(name, ConvertElementSequence(wrapper)); + break; + case "else-if": + var inner = wrapper.Elements().FirstOrDefault(); + if (inner is not null) + { + node.Add("elseIf", ConvertElement(inner)); + } + break; + case "columns": + node.Add("columns", ConvertAttributeItemSequence(wrapper)); + break; + case "rows": + node.Add("rows", ConvertAttributeItemSequence(wrapper)); + break; + case "series": + // Handled at element level for charts; placeholder (overridden in chart task). + break; + case "categories": + case "x-labels": + case "y-labels": + node.Add(name, ConvertScalarItemSequence(wrapper)); + break; + case "palette": + node.Add("palette", ConvertScalarItemSequence(wrapper)); + break; + case "shapes": + // Handled in the draw task. + break; + } + } + + /// Converts child layout elements of a wrapper into a YAML sequence of mappings. + private static YamlSequenceNode ConvertElementSequence(XElement wrapper) + { + var seq = new YamlSequenceNode(); + foreach (var child in wrapper.Elements()) + { + seq.Add(ConvertElement(child)); + } + return seq; + } + + /// Converts child elements whose attributes become mapping fields (table column/row). + private static YamlSequenceNode ConvertAttributeItemSequence(XElement wrapper) + { + var seq = new YamlSequenceNode(); + foreach (var child in wrapper.Elements()) + { + seq.Add(AttributesToMapping(child)); + } + return seq; + } + + /// Converts <item>value</item> / <color> children into a scalar sequence. + private static YamlSequenceNode ConvertScalarItemSequence(XElement wrapper) + { + var seq = new YamlSequenceNode(); + foreach (var child in wrapper.Elements()) + { + seq.Add(new YamlScalarNode(child.Value.Trim())); + } + return seq; + } + + /// Converts a <fonts> wrapper into a YAML sequence of font entry mappings. + private static YamlSequenceNode ConvertFonts(XElement fonts) + { + var seq = new YamlSequenceNode(); + foreach (var font in fonts.Elements()) + { + seq.Add(AttributesToMapping(font)); + } + return seq; + } + + /// + /// Expands a comma-separated (or "x,y; x,y" tuple) attribute value into a YAML sequence. + /// A value containing a template expression is left as a scalar. + /// + private static YamlNode ExpandListAttribute(string value) + { + if (value.Contains("{{", StringComparison.Ordinal)) + { + return new YamlScalarNode(value); + } + + // Tuple list: "1,2; 3,4" -> [[1,2],[3,4]] + if (value.Contains(';', StringComparison.Ordinal)) + { + var outer = new YamlSequenceNode(); + foreach (var part in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var inner = new YamlSequenceNode(); + foreach (var comp in part.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + inner.Add(new YamlScalarNode(comp)); + } + outer.Add(inner); + } + return outer; + } + + var seq = new YamlSequenceNode(); + foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + seq.Add(new YamlScalarNode(item)); + } + return seq; + } + + /// Adds an XML attribute to a mapping node when present and non-empty. + private static void AddAttrIfPresent(XElement el, string name, YamlMappingNode node) + { + var attr = el.Attribute(name); + if (attr is not null && !string.IsNullOrEmpty(attr.Value)) + { + node.Add(name, new YamlScalarNode(attr.Value)); + } + } +} diff --git a/tests/FlexRender.Tests/FlexRender.Tests.csproj b/tests/FlexRender.Tests/FlexRender.Tests.csproj index 9fb45c2..1324538 100644 --- a/tests/FlexRender.Tests/FlexRender.Tests.csproj +++ b/tests/FlexRender.Tests/FlexRender.Tests.csproj @@ -27,6 +27,7 @@ + diff --git a/tests/FlexRender.Tests/Parsing/Xml/XmlTemplateParserBasicTests.cs b/tests/FlexRender.Tests/Parsing/Xml/XmlTemplateParserBasicTests.cs new file mode 100644 index 0000000..aad2cd9 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Xml/XmlTemplateParserBasicTests.cs @@ -0,0 +1,84 @@ +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for basic XML template parsing (canvas, text, separator). +/// +public class XmlTemplateParserBasicTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_CanvasAndText_ContentAttribute() + { + const string xml = """ + + + + + """; + + var template = _parser.Parse(xml); + + Assert.Equal(300, template.Canvas.Width); + var text = Assert.IsType(Assert.Single(template.Elements)); + Assert.Equal("Hello World", text.Content); + Assert.Equal("1.5em", text.Size.Value); + Assert.Equal("#ff0000", text.Color.Value); + } + + [Fact] + public void Parse_TextInnerTextUsedAsContent() + { + const string xml = """ + + + Inline body + + """; + + var text = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal("Inline body", text.Content); + } + + [Fact] + public void Parse_Separator() + { + const string xml = """ + + + + + """; + + var sep = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(SeparatorOrientation.Horizontal, sep.Orientation); + Assert.Equal(SeparatorStyle.Dashed, sep.Style); + Assert.Equal(2f, sep.Thickness); + } + + [Fact] + public void Parse_NullContent_Throws() + { + Assert.Throws(() => _parser.Parse((string)null!)); + } + + [Fact] + public void Parse_MissingCanvas_Throws() + { + const string xml = ""; + var ex = Assert.Throws(() => _parser.Parse(xml)); + Assert.Contains("canvas", ex.Message, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Parse_MalformedXml_ThrowsTemplateParseException() + { + const string xml = ""; + Assert.Throws(() => _parser.Parse(xml)); + } +} From 8954fd88b1764854064d7e9e27d2ea395a5f9ef5 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:14:45 +0400 Subject: [PATCH 096/119] test: XML flex nesting and qr/barcode/image elements Exclude 'data' from generic list-attribute expansion so top-level qr/barcode data attributes stay scalar; chart-series data is expanded by its dedicated converter. --- src/FlexRender.Xml/XmlToYamlNodeConverter.cs | 7 +- .../Parsing/Xml/XmlFlexNestingTests.cs | 66 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/FlexRender.Tests/Parsing/Xml/XmlFlexNestingTests.cs diff --git a/src/FlexRender.Xml/XmlToYamlNodeConverter.cs b/src/FlexRender.Xml/XmlToYamlNodeConverter.cs index 5c2388e..68552b0 100644 --- a/src/FlexRender.Xml/XmlToYamlNodeConverter.cs +++ b/src/FlexRender.Xml/XmlToYamlNodeConverter.cs @@ -28,9 +28,14 @@ internal static class XmlToYamlNodeConverter /// /// Attribute names whose comma/semicolon values expand into YAML sequences. /// + /// + /// The data attribute is intentionally excluded: as a top-level element attribute it is a + /// scalar (e.g. <qr data="hello"/>, <barcode data="12345"/>). Chart-series + /// data is expanded by the dedicated series converter, not by this generic attribute pass. + /// private static readonly HashSet ListAttributes = new(StringComparer.Ordinal) { - "data", "points", "categories", "palette", "x-labels", "y-labels" + "points", "categories", "palette", "x-labels", "y-labels" }; /// diff --git a/tests/FlexRender.Tests/Parsing/Xml/XmlFlexNestingTests.cs b/tests/FlexRender.Tests/Parsing/Xml/XmlFlexNestingTests.cs new file mode 100644 index 0000000..37ed821 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Xml/XmlFlexNestingTests.cs @@ -0,0 +1,66 @@ +using FlexRender.Layout; +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for nested flex containers and leaf elements via the XML parser. +/// +public class XmlFlexNestingTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_NestedFlexWithChildren() + { + const string xml = """ + + + + + + + + + + + """; + + var flex = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(FlexDirection.Row, flex.Direction); + Assert.Equal(JustifyContent.Center, flex.Justify); + Assert.Equal(2, flex.Children.Count); + + var inner = Assert.IsType(flex.Children[1]); + Assert.Equal(FlexDirection.Column, inner.Direction); + Assert.Equal(2, inner.Children.Count); + Assert.Equal("A", Assert.IsType(inner.Children[0]).Content); + } + + [Fact] + public void Parse_QrBarcodeImage() + { + const string xml = """ + + + + + + + """; + + var elements = _parser.Parse(xml).Elements; + var qr = Assert.IsType(elements[0]); + Assert.Equal("hello", qr.Data.Value); + Assert.Equal(ErrorCorrectionLevel.H, qr.ErrorCorrection.Value); + + var barcode = Assert.IsType(elements[1]); + Assert.Equal(BarcodeFormat.Ean13, barcode.Format.Value); + + var image = Assert.IsType(elements[2]); + Assert.Equal("logo.png", image.Src.Value); + Assert.Equal(ImageFit.Cover, image.Fit.Value); + } +} From f4dd145dae401cb57a39301a053a6bf0cc47ef35 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:15:21 +0400 Subject: [PATCH 097/119] test: XML each/if control-flow parsing --- .../Parsing/Xml/XmlControlFlowTests.cs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs diff --git a/tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs b/tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs new file mode 100644 index 0000000..542f9ee --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs @@ -0,0 +1,81 @@ +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for each/if control-flow elements via the XML parser. +/// +public class XmlControlFlowTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_Each_WithChildren() + { + const string xml = """ + + + + + + + """; + + var each = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal("items", each.ArrayPath); + Assert.Equal("item", each.ItemVariable); + Assert.Single(each.ItemTemplate); + Assert.IsType(each.ItemTemplate[0]); + } + + [Fact] + public void Parse_If_ThenElse() + { + const string xml = """ + + + + + + + + + + + + """; + + var ifEl = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal("paid", ifEl.ConditionPath); + Assert.Equal(ConditionOperator.Equals, ifEl.Operator); + Assert.Equal("true", ifEl.CompareValue); + Assert.Single(ifEl.ThenBranch); + Assert.Single(ifEl.ElseBranch); + Assert.Equal("PAID", Assert.IsType(ifEl.ThenBranch[0]).Content); + Assert.Equal("DUE", Assert.IsType(ifEl.ElseBranch[0]).Content); + } + + [Fact] + public void Parse_If_ElseIf() + { + const string xml = """ + + + + + + + + + + + + """; + + var ifEl = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.NotNull(ifEl.ElseIf); + Assert.Equal("warm", ifEl.ElseIf!.CompareValue); + } +} From 03a436e479d726ef13bfdfb366040011faecb808 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:15:49 +0400 Subject: [PATCH 098/119] test: XML table columns/rows parsing --- .../Parsing/Xml/XmlTableTests.cs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/FlexRender.Tests/Parsing/Xml/XmlTableTests.cs diff --git a/tests/FlexRender.Tests/Parsing/Xml/XmlTableTests.cs b/tests/FlexRender.Tests/Parsing/Xml/XmlTableTests.cs new file mode 100644 index 0000000..9edb762 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Xml/XmlTableTests.cs @@ -0,0 +1,61 @@ +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for the table element via the XML parser. +/// +public class XmlTableTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_DynamicTable_Columns() + { + const string xml = """ + + + + + + + +
+
+ """; + + var table = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal("lines", table.ArrayPath); + Assert.Equal(2, table.Columns.Count); + Assert.Equal("name", table.Columns[0].Key); + Assert.Equal("Item", table.Columns[0].Label); + Assert.Equal(TextAlign.Right, table.Columns[1].Align); + } + + [Fact] + public void Parse_StaticTable_Rows() + { + const string xml = """ + + + + + + + + + + + +
+
+ """; + + var table = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(2, table.Rows.Count); + Assert.Equal("Coffee", table.Rows[0].Values["name"]); + Assert.Equal("1", table.Rows[1].Values["qty"]); + } +} From 24483a63b0fb798f3c0aaffd3338b7382510ed8a Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:16:49 +0400 Subject: [PATCH 099/119] feat: XML draw shapes wrapper translation --- src/FlexRender.Xml/XmlToYamlNodeConverter.cs | 32 +++++++++- .../Parsing/Xml/XmlShapeTests.cs | 59 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/FlexRender.Tests/Parsing/Xml/XmlShapeTests.cs diff --git a/src/FlexRender.Xml/XmlToYamlNodeConverter.cs b/src/FlexRender.Xml/XmlToYamlNodeConverter.cs index 68552b0..68315cc 100644 --- a/src/FlexRender.Xml/XmlToYamlNodeConverter.cs +++ b/src/FlexRender.Xml/XmlToYamlNodeConverter.cs @@ -216,7 +216,7 @@ private static void AddWrapper(YamlMappingNode node, string parentType, XElement node.Add("palette", ConvertScalarItemSequence(wrapper)); break; case "shapes": - // Handled in the draw task. + node.Add("shapes", ConvertShapeSequence(wrapper)); break; } } @@ -254,6 +254,36 @@ private static YamlSequenceNode ConvertScalarItemSequence(XElement wrapper) return seq; } + /// + /// Converts a <shapes> wrapper into the YAML shapes sequence, where each shape + /// becomes a mapping with a single shape-kind key (line/polyline/rect/circle/path) whose value + /// is the shape's attribute mapping (with any list attributes such as points expanded). + /// + /// The <shapes> wrapper element holding one element per shape. + /// The YAML sequence the shared draw-shape parser consumes. + private static YamlSequenceNode ConvertShapeSequence(XElement wrapper) + { + var seq = new YamlSequenceNode(); + foreach (var shape in wrapper.Elements()) + { + var shapeMapping = new YamlMappingNode(); + foreach (var attr in shape.Attributes()) + { + var name = attr.Name.LocalName; + shapeMapping.Add( + name, + ListAttributes.Contains(name) + ? ExpandListAttribute(attr.Value) + : new YamlScalarNode(attr.Value)); + } + + var wrapped = new YamlMappingNode(); + wrapped.Add(shape.Name.LocalName, shapeMapping); + seq.Add(wrapped); + } + return seq; + } + /// Converts a <fonts> wrapper into a YAML sequence of font entry mappings. private static YamlSequenceNode ConvertFonts(XElement fonts) { diff --git a/tests/FlexRender.Tests/Parsing/Xml/XmlShapeTests.cs b/tests/FlexRender.Tests/Parsing/Xml/XmlShapeTests.cs new file mode 100644 index 0000000..e114c6a --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Xml/XmlShapeTests.cs @@ -0,0 +1,59 @@ +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for shape elements (rect, circle, ellipse, draw) via the XML parser. +/// +public class XmlShapeTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_RectCircleEllipse() + { + const string xml = """ + + + + + + + """; + + var elements = _parser.Parse(xml).Elements; + var rect = Assert.IsType(elements[0]); + Assert.Equal("#4A90D9", rect.Fill.Value); + Assert.Equal(2f, rect.StrokeWidth.Value); + + Assert.IsType(elements[1]); + Assert.IsType(elements[2]); + } + + [Fact] + public void Parse_Draw_WithShapes() + { + const string xml = """ + + + + + + + + + + + + """; + + var draw = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(4, draw.Shapes.Count); + Assert.IsType(draw.Shapes[0]); + Assert.IsType(draw.Shapes[1]); + Assert.IsType(draw.Shapes[2]); + Assert.IsType(draw.Shapes[3]); + } +} From 173529aa2f0a9e7483f9719614f5887876b3a5ef Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:21:19 +0400 Subject: [PATCH 100/119] feat: XML chart series, categories, palette, tuple data --- src/FlexRender.Xml/XmlToYamlNodeConverter.cs | 47 +++++- .../Parsing/Xml/XmlChartTests.cs | 146 ++++++++++++++++++ 2 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 tests/FlexRender.Tests/Parsing/Xml/XmlChartTests.cs diff --git a/src/FlexRender.Xml/XmlToYamlNodeConverter.cs b/src/FlexRender.Xml/XmlToYamlNodeConverter.cs index 68315cc..e9c8230 100644 --- a/src/FlexRender.Xml/XmlToYamlNodeConverter.cs +++ b/src/FlexRender.Xml/XmlToYamlNodeConverter.cs @@ -21,7 +21,7 @@ internal static class XmlToYamlNodeConverter ///
private static readonly HashSet WrapperNames = new(StringComparer.Ordinal) { - "then", "else", "else-if", "columns", "rows", "series", + "then", "else", "else-if", "columns", "rows", "categories", "x-labels", "y-labels", "palette", "shapes" }; @@ -149,10 +149,15 @@ private static YamlMappingNode ConvertElement(XElement el) // Child elements. var naturalList = new YamlSequenceNode(); + var seriesList = new YamlSequenceNode(); foreach (var child in el.Elements()) { var childName = child.Name.LocalName; - if (WrapperNames.Contains(childName)) + if (string.Equals(childName, "series", StringComparison.Ordinal)) + { + seriesList.Add(ConvertSeries(child)); + } + else if (WrapperNames.Contains(childName)) { AddWrapper(node, type, child); } @@ -162,6 +167,11 @@ private static YamlMappingNode ConvertElement(XElement el) } } + if (seriesList.Children.Count > 0) + { + node.Add("series", seriesList); + } + if (naturalList.Children.Count > 0) { node.Add(NaturalListKey(type), naturalList); @@ -204,9 +214,6 @@ private static void AddWrapper(YamlMappingNode node, string parentType, XElement case "rows": node.Add("rows", ConvertAttributeItemSequence(wrapper)); break; - case "series": - // Handled at element level for charts; placeholder (overridden in chart task). - break; case "categories": case "x-labels": case "y-labels": @@ -221,6 +228,36 @@ private static void AddWrapper(YamlMappingNode node, string parentType, XElement } } + /// + /// Converts a <series> element into a YAML series mapping the shared chart parser consumes. + /// The XML data attribute (comma-separated numbers or a {{expression}}) and the XML + /// points attribute (semicolon-separated x,y[,r] tuples for scatter/bubble) both map + /// to the YAML data key: a flat number array for data and an array-of-arrays for + /// points, exactly the two shapes produces and the chart + /// parser's flat / tuple branches expect. All other attributes (e.g. label) become scalars. + /// + /// The <series> element. + /// The YAML series mapping node. + private static YamlMappingNode ConvertSeries(XElement series) + { + var node = new YamlMappingNode(); + foreach (var attr in series.Attributes()) + { + var name = attr.Name.LocalName; + switch (name) + { + case "data": + case "points": + node.Add("data", ExpandListAttribute(attr.Value)); + break; + default: + node.Add(name, new YamlScalarNode(attr.Value)); + break; + } + } + return node; + } + /// Converts child layout elements of a wrapper into a YAML sequence of mappings. private static YamlSequenceNode ConvertElementSequence(XElement wrapper) { diff --git a/tests/FlexRender.Tests/Parsing/Xml/XmlChartTests.cs b/tests/FlexRender.Tests/Parsing/Xml/XmlChartTests.cs new file mode 100644 index 0000000..ee765eb --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Xml/XmlChartTests.cs @@ -0,0 +1,146 @@ +using FlexRender.Charts; +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for the chart element via the XML parser, covering series collection, inline and tuple +/// data, categories, palette, gauge scalars, and heatmap rows/labels. Each case asserts the +/// resulting matches the equivalent YAML chart AST. +/// +public class XmlChartTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_BarChart_SeriesAndCategories() + { + const string xml = """ + + + + + Q1Q2Q3Q4 + + + + + + """; + + var chart = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(ChartType.Bar, chart.ChartType); + Assert.Equal("Sales", chart.Title); + Assert.Equal(new[] { "Q1", "Q2", "Q3", "Q4" }, chart.Categories); + Assert.Equal(2, chart.Series.Count); + Assert.Equal("2024", chart.Series[0].Label); + Assert.Equal(new[] { 12d, 30d, 22d, 48d }, chart.Series[0].Data); + Assert.Equal("2025", chart.Series[1].Label); + Assert.Equal(new[] { 18d, 26d, 31d, 40d }, chart.Series[1].Data); + } + + [Fact] + public void Parse_NamedPalette_Attribute() + { + const string xml = """ + + + + + + + """; + + var chart = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.NotNull(chart.Palette); + } + + [Fact] + public void Parse_ExplicitColorPalette_List() + { + const string xml = """ + + + + + + + """; + + var chart = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.NotNull(chart.Palette); + Assert.Equal(new[] { "#ff0000", "#00ff00", "#0000ff" }, chart.Palette!.Colors); + } + + [Fact] + public void Parse_ScatterTupleData() + { + const string xml = """ + + + + + + + """; + + var chart = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(ChartType.Scatter, chart.ChartType); + var series = Assert.Single(chart.Series); + Assert.Equal("pts", series.Label); + Assert.Equal(3, series.Points.Count); + Assert.Equal(1d, series.Points[0].X); + Assert.Equal(2d, series.Points[0].Y); + Assert.Equal(3d, series.Points[1].X); + Assert.Equal(4d, series.Points[1].Y); + Assert.Equal(5d, series.Points[2].X); + Assert.Equal(6d, series.Points[2].Y); + } + + [Fact] + public void Parse_Gauge_ValueAndMax() + { + const string xml = """ + + + + + """; + + var chart = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(ChartType.Gauge, chart.ChartType); + Assert.Equal(72d, chart.Value); + Assert.Equal(100d, chart.Max); + Assert.Equal("CPU", chart.ValueLabel); + } + + [Fact] + public void Parse_Heatmap_RowsAndLabels() + { + const string xml = """ + + + + + MonTueWed + + + AMPM + + + + + + """; + + var chart = Assert.IsType(Assert.Single(_parser.Parse(xml).Elements)); + Assert.Equal(ChartType.Heatmap, chart.ChartType); + Assert.Equal(new[] { "Mon", "Tue", "Wed" }, chart.XLabels); + Assert.Equal(new[] { "AM", "PM" }, chart.YLabels); + Assert.Equal(2, chart.Series.Count); + Assert.Equal(new[] { 2d, 8d, 4d }, chart.Series[0].Data); + Assert.Equal(new[] { 6d, 1d, 9d }, chart.Series[1].Data); + } +} From ed9ec3b0fa53ba08df1d02d83664040d323710e5 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:23:12 +0400 Subject: [PATCH 101/119] test: XML unknown attribute/element validation and typo hints --- .../Parsing/Xml/XmlValidationTests.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/FlexRender.Tests/Parsing/Xml/XmlValidationTests.cs diff --git a/tests/FlexRender.Tests/Parsing/Xml/XmlValidationTests.cs b/tests/FlexRender.Tests/Parsing/Xml/XmlValidationTests.cs new file mode 100644 index 0000000..ad3e378 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Xml/XmlValidationTests.cs @@ -0,0 +1,56 @@ +using FlexRender.Parsing; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests that XML attribute validation reuses the YAML KnownProperties machinery. +/// +public class XmlValidationTests +{ + private readonly XmlTemplateParser _parser = new(); + + [Fact] + public void Parse_UnknownAttribute_ThrowsWithSuggestion() + { + const string xml = """ + + + + + """; + + var ex = Assert.Throws(() => _parser.Parse(xml)); + Assert.Contains("colour", ex.Message, System.StringComparison.Ordinal); + Assert.Contains("color", ex.Message, System.StringComparison.Ordinal); + } + + [Fact] + public void Parse_UnknownElementType_Throws() + { + const string xml = """ + + + + + """; + + var ex = Assert.Throws(() => _parser.Parse(xml)); + Assert.Contains("Unknown element type", ex.Message, System.StringComparison.Ordinal); + } + + [Fact] + public void Parse_CaseMismatchAttribute_HintsCaseSensitivity() + { + const string xml = """ + + + + + """; + + var ex = Assert.Throws(() => _parser.Parse(xml)); + Assert.Contains("case-sensitive", ex.Message, System.StringComparison.Ordinal); + } +} From 0714f30fd21f83dcd29ad36f8f43772071449dc3 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:23:53 +0400 Subject: [PATCH 102/119] test: XML parser enforces resource limits --- .../Parsing/Xml/XmlResourceLimitTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/FlexRender.Tests/Parsing/Xml/XmlResourceLimitTests.cs diff --git a/tests/FlexRender.Tests/Parsing/Xml/XmlResourceLimitTests.cs b/tests/FlexRender.Tests/Parsing/Xml/XmlResourceLimitTests.cs new file mode 100644 index 0000000..d470e58 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Xml/XmlResourceLimitTests.cs @@ -0,0 +1,51 @@ +using System.Text; +using FlexRender.Configuration; +using FlexRender.Parsing; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests that ResourceLimits are enforced by the XML parser via the shared TemplateParser. +/// +public class XmlResourceLimitTests +{ + [Fact] + public void Parse_TooManyShapes_Throws() + { + var limits = new ResourceLimits { MaxShapesPerDraw = 2 }; + var parser = new XmlTemplateParser(limits); + + var sb = new StringBuilder(); + sb.Append(""); + for (var i = 0; i < 3; i++) + { + sb.Append(""); + } + sb.Append(""); + + var ex = Assert.Throws(() => parser.Parse(sb.ToString())); + Assert.Contains("exceeds the maximum", ex.Message, System.StringComparison.Ordinal); + } + + [Fact] + public void Parse_TooManySeries_Throws() + { + var limits = new ResourceLimits { MaxSeriesPerChart = 1 }; + var parser = new XmlTemplateParser(limits); + + const string xml = """ + + + + + + + + """; + + var ex = Assert.Throws(() => parser.Parse(xml)); + Assert.Contains("exceeds the maximum", ex.Message, System.StringComparison.Ordinal); + } +} From 82746f6d1f5aa6dca1c1f0836fbd963491113564 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:24:56 +0400 Subject: [PATCH 103/119] test: XML/YAML AST equivalence cross-check --- .../Parsing/Xml/XmlYamlEquivalenceTests.cs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/FlexRender.Tests/Parsing/Xml/XmlYamlEquivalenceTests.cs diff --git a/tests/FlexRender.Tests/Parsing/Xml/XmlYamlEquivalenceTests.cs b/tests/FlexRender.Tests/Parsing/Xml/XmlYamlEquivalenceTests.cs new file mode 100644 index 0000000..b1b91a7 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Xml/XmlYamlEquivalenceTests.cs @@ -0,0 +1,77 @@ +using FlexRender.Parsing; +using FlexRender.Parsing.Ast; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Cross-checks that XML and YAML parsers produce equivalent ASTs for the same template. +/// +public class XmlYamlEquivalenceTests +{ + private const string Yaml = """ + canvas: + width: 400 + layout: + - type: flex + direction: row + gap: 8 + children: + - type: text + content: "Quarterly sales" + size: 1.2em + - type: chart + chart-type: bar + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: [12, 30, 22, 48] + - label: "2025" + data: [18, 26, 31, 40] + """; + + private const string Xml = """ + + + + Quarterly sales + + + Q1Q2Q3Q4 + + + + + + + """; + + [Fact] + public void XmlAndYaml_ProduceEquivalentAst() + { + var fromYaml = new TemplateParser().Parse(Yaml); + var fromXml = new XmlTemplateParser().Parse(Xml); + + Assert.Equal(fromYaml.Canvas.Width, fromXml.Canvas.Width); + + var yamlFlex = Assert.IsType(Assert.Single(fromYaml.Elements)); + var xmlFlex = Assert.IsType(Assert.Single(fromXml.Elements)); + Assert.Equal(yamlFlex.Direction, xmlFlex.Direction); + Assert.Equal(yamlFlex.Children.Count, xmlFlex.Children.Count); + + var yamlText = Assert.IsType(yamlFlex.Children[0]); + var xmlText = Assert.IsType(xmlFlex.Children[0]); + Assert.Equal(yamlText.Content, xmlText.Content); + Assert.Equal(yamlText.Size.Value, xmlText.Size.Value); + + var yamlChart = Assert.IsType(yamlFlex.Children[1]); + var xmlChart = Assert.IsType(xmlFlex.Children[1]); + Assert.Equal(yamlChart.ChartType, xmlChart.ChartType); + Assert.Equal(yamlChart.Categories, xmlChart.Categories); + Assert.Equal(yamlChart.Series.Count, xmlChart.Series.Count); + Assert.Equal(yamlChart.Series[0].Label, xmlChart.Series[0].Label); + Assert.Equal(yamlChart.Series[0].Data, xmlChart.Series[0].Data); + Assert.Equal(yamlChart.Series[1].Data, xmlChart.Series[1].Data); + } +} From a15c34e76e770558268436aee91c857183bcc7c6 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:27:33 +0400 Subject: [PATCH 104/119] feat: RenderXml extension method --- src/FlexRender.Xml/XmlFlexRenderExtensions.cs | 61 +++++++++++++++++++ .../Parsing/Xml/XmlRenderExtensionTests.cs | 34 +++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/FlexRender.Xml/XmlFlexRenderExtensions.cs create mode 100644 tests/FlexRender.Tests/Parsing/Xml/XmlRenderExtensionTests.cs diff --git a/src/FlexRender.Xml/XmlFlexRenderExtensions.cs b/src/FlexRender.Xml/XmlFlexRenderExtensions.cs new file mode 100644 index 0000000..a2061ae --- /dev/null +++ b/src/FlexRender.Xml/XmlFlexRenderExtensions.cs @@ -0,0 +1,61 @@ +using FlexRender.Abstractions; +using FlexRender.Parsing; + +namespace FlexRender.Xml; + +/// +/// Extension methods for that provide XML template rendering capabilities. +/// +/// +/// These methods mirror the YAML RenderYaml extensions: the XML is parsed into the shared +/// AST via and handed to the same +/// +/// downstream path used for YAML rendering. +/// +public static class XmlFlexRenderExtensions +{ + /// + /// Renders an XML template string to a byte array. + /// + /// The render instance to use. + /// The XML template content. + /// Optional data for template variable substitution. + /// Output image format. Defaults to PNG. + /// Optional XML template parser for reuse across multiple calls. + /// Token to cancel the rendering operation. + /// A task that represents the asynchronous operation. The task result contains the image bytes in the specified format. + /// Thrown when or is null. + /// Thrown when the XML content cannot be parsed. + /// Thrown when rendering fails due to invalid template structure or resource loading errors. + /// + /// + /// var render = new FlexRenderBuilder() + /// .WithSkia() + /// .Build(); + /// + /// var xml = @" + /// <flexrender> + /// <canvas width=""300"" height=""200"" /> + /// <text content=""Hello, {{name}}!"" /> + /// </flexrender>"; + /// + /// var data = new ObjectValue { ["name"] = new StringValue("World") }; + /// var bytes = await render.RenderXml(xml, data); + /// + /// + public static async Task RenderXml( + this IFlexRender render, + string xml, + ObjectValue? data = null, + ImageFormat format = ImageFormat.Png, + XmlTemplateParser? parser = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(render); + ArgumentNullException.ThrowIfNull(xml); + + parser ??= new XmlTemplateParser(); + var template = parser.Parse(xml); + return await render.Render(template, data, format, cancellationToken).ConfigureAwait(false); + } +} diff --git a/tests/FlexRender.Tests/Parsing/Xml/XmlRenderExtensionTests.cs b/tests/FlexRender.Tests/Parsing/Xml/XmlRenderExtensionTests.cs new file mode 100644 index 0000000..5cd9205 --- /dev/null +++ b/tests/FlexRender.Tests/Parsing/Xml/XmlRenderExtensionTests.cs @@ -0,0 +1,34 @@ +using FlexRender.Configuration; +using FlexRender.Xml; +using Xunit; + +namespace FlexRender.Tests.Parsing.Xml; + +/// +/// Tests for the RenderXml extension method. +/// +public sealed class XmlRenderExtensionTests +{ + /// + /// Verifies that RenderXml parses an XML template and renders it end-to-end to non-empty image bytes. + /// + [Fact] + public async Task RenderXml_ProducesNonEmptyImage() + { + using var render = new FlexRenderBuilder() + .WithSkia() + .Build(); + + const string xml = """ + + + + + """; + + var bytes = await render.RenderXml(xml); + + Assert.NotNull(bytes); + Assert.NotEmpty(bytes); + } +} From 1ecc9fa6fdd9b3dc676e9708436b0468396f667d Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:28:12 +0400 Subject: [PATCH 105/119] build: include FlexRender.Xml in meta-package --- src/FlexRender.MetaPackage/FlexRender.MetaPackage.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/FlexRender.MetaPackage/FlexRender.MetaPackage.csproj b/src/FlexRender.MetaPackage/FlexRender.MetaPackage.csproj index 92d4b58..654d43f 100644 --- a/src/FlexRender.MetaPackage/FlexRender.MetaPackage.csproj +++ b/src/FlexRender.MetaPackage/FlexRender.MetaPackage.csproj @@ -12,6 +12,7 @@ + From 6aa5f7d77e200d58c396892a86a10bd00471967f Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:32:17 +0400 Subject: [PATCH 106/119] docs: document XML template syntax --- AGENTS.md | 2 + docs/wiki/Home.md | 1 + docs/wiki/Xml-Syntax.md | 149 ++++++++++++++++++++++++++++++++++++++++ llms-full.txt | 40 +++++++++++ llms.txt | 4 ++ 5 files changed, 196 insertions(+) create mode 100644 docs/wiki/Xml-Syntax.md diff --git a/AGENTS.md b/AGENTS.md index 17016b2..ce335b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,8 @@ src/FlexRender.Core/ # Core library (0 external dependencies) src/FlexRender.Yaml/ # YAML template parser (-> Core + YamlDotNet) Parsing/ # TemplateParser +src/FlexRender.Xml/ # XML template parser, alternative to YAML (same Template AST); RenderXml extension + Parsing/ # XmlTemplateParser src/FlexRender.Http/ # HTTP resource loader (-> Core) src/FlexRender.Skia.Render/ # SkiaSharp renderer (-> Core + SkiaSharp) Abstractions/ # ISkiaRenderer, IFontLoader, IImageLoader, IFontManager diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index ec011e0..2360b39 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -94,6 +94,7 @@ byte[] png = await render.RenderFile("template.yaml", data); |------|-------------| | [[Getting-Started]] | Installation, first template, rendering approaches | | [[Template-Syntax]] | Canvas, all 11 element types, common properties, units | +| [[Xml-Syntax]] | Alternative XML template syntax (same AST as YAML), `RenderXml` | | [[Element-Reference]] | Complete property reference for all 11 element types with examples | | [[Visual-Reference]] | Interactive visual examples for all properties and elements | | [[Template-Expressions]] | Variables, loops, conditionals with 13 operators | diff --git a/docs/wiki/Xml-Syntax.md b/docs/wiki/Xml-Syntax.md new file mode 100644 index 0000000..31c6f0f --- /dev/null +++ b/docs/wiki/Xml-Syntax.md @@ -0,0 +1,149 @@ +# XML Syntax + +FlexRender templates can be authored in **XML** as an alternative to YAML. The XML parser (`FlexRender.Xml`) produces the **same `Template` AST** as the YAML parser -- same element types, same properties, same validation, same resource limits. Nothing downstream of parsing changes. + +XML is offered because LLMs and code generators often emit XML attributes more reliably than YAML indentation. Pick whichever syntax suits your tooling; both render identically. + +For the YAML syntax and the full property reference, see [[Template-Syntax]] and [[Element-Reference]]. For template expressions (variables, loops, conditionals), see [[Template-Expressions]]. + +## At a Glance + +```xml + + + Hello + +``` + +```csharp +var render = new FlexRenderBuilder() + .WithSkia(skia => skia.WithQr().WithBarcode()) + .Build(); + +byte[] png = await render.RenderXml(xml, data); +``` + +The XML parser does **not** reimplement element parsing. It converts the XML tree into the equivalent structure the YAML parser produces and reuses the existing element parsers. The mapping rules below define that conversion. They are designed to be LLM-friendly (attributes over indentation) while round-tripping cleanly to the same AST. + +## Document Shape + +| YAML | XML | +| --- | --- | +| top-level `template:`, `canvas:`, `fonts:`, `layout:` | a single root element `` | +| `canvas: { width: 300, ... }` | `` child of `` | +| `template: { name, version, culture }` | attributes on ``: `name`, `version`, `culture` | +| `layout: [ ...elements ]` | every **other** child element of `` (in order) is a layout element | +| `fonts: { default: "x.ttf" }` | `` child containing `` entries | + +## Element Shape + +- **Element type = XML local element name.** `` -> `type: text`, `` -> `type: flex`, ``, ``, ``, ``, ``, etc. The set of names is exactly the YAML element types. +- **Scalar properties = XML attributes.** `` -> `size: 1em`, `color: "#ff0000"`. Attribute names are identical to YAML property names (kebab-case and camelCase both pass through unchanged, e.g. `chart-type`, `stroke-width`, `min-width`, `borderColor`). Validation against `KnownProperties` is unchanged. +- **`text` content** may be given either as the `content` attribute **or** as the element's inner text: `Hello` is equivalent to ``. If both are present, the `content` attribute wins. (svg `content` follows the same rule.) +- **Child-element containers** map to list properties: + - `flex` children -> child layout elements directly inside ``. + - `each` body -> child layout elements directly inside `` (YAML `children`). + - `if` -> `` and `` wrapper children, each holding layout elements; optional `` wrapper holds a single nested `if` (YAML `elseIf`). Comparison operators (`equals`, `in`, `greaterThan`, ...) remain attributes on ``. + - `table` -> `` wrapper holding `` entries; optional `` wrapper holding `` entries. Column/row fields are attributes. + - `chart` -> `` entries (each a child of ``), `` wrapper holding `value` entries, ``/`` wrappers holding `` entries, `` wrapper holding `#fff` entries (or a `palette="ocean"` attribute for a named palette). + - `draw` -> `` wrapper holding one-shape-per-child elements ``, ``, ``, ``, ``. +- **Scalar lists** (chart `series.data`, `categories`, polyline `points`): + - `series` numeric data: `data="12,30,22,48"` attribute (comma-separated) -> YAML `data: [12,30,22,48]`. An expression `data="{{ sales }}"` passes through as a scalar string. + - tuple data (scatter/bubble): `points="1,2; 3,4; 5,6"` attribute on `` -> YAML `data: [[1,2],[3,4],[5,6]]`. (Semicolon separates points, comma separates components.) + - `draw polyline` `points="10,10; 50,50"` -> YAML `points: [[10,10],[50,50]]`. + +## How the Converter Decides Attribute vs. List-Container + +The converter is generic and data-driven. For each XML element it produces a mapping with: + +1. `type` = the element local-name (except the special wrappers below). +2. one scalar entry per attribute (name -> value), with comma/semicolon list attributes expanded into a sequence when the attribute name is a known list property (`data`, `points`, `categories`, `palette`, `x-labels`, `y-labels`). +3. one entry per recognised **child wrapper**: child elements named `then`/`else`/`else-if`/`columns`/`rows`/`series`/`categories`/`x-labels`/`y-labels`/`palette`/`shapes` become the corresponding sequence/mapping; all **other** child elements are collected into the container's natural list key (`children` for `flex`/`each`, `layout` for the root, the shape list for `draw`, etc.). + +## Side-by-Side Example (flex + chart) + +YAML: + +```yaml +canvas: + width: 400 +layout: + - type: flex + direction: row + gap: 8 + children: + - type: text + content: "Quarterly sales" + size: 1.2em + - type: chart + chart-type: bar + categories: [Q1, Q2, Q3, Q4] + series: + - label: "2024" + data: [12, 30, 22, 48] + - label: "2025" + data: [18, 26, 31, 40] +``` + +XML: + +```xml + + + + Quarterly sales + + + Q1Q2Q3Q4 + + + + + + +``` + +Both parse to the identical `Template` AST. + +## Rendering XML + +Use the `RenderXml` extension method, which mirrors `RenderYaml`: it parses the XML string into the shared `Template` AST via `XmlTemplateParser` and hands it to the same render pipeline. + +```csharp +var render = new FlexRenderBuilder() + .WithSkia() + .Build(); + +var xml = """ + + + + +"""; + +var data = new ObjectValue { ["name"] = new StringValue("World") }; +byte[] png = await render.RenderXml(xml, data); +``` + +To parse once and reuse, create an `XmlTemplateParser` and pass it via the optional `parser` argument (the same pattern as `TemplateParser` for YAML): + +```csharp +var parser = new XmlTemplateParser(); +byte[] png = await render.RenderXml(xml, data, parser: parser); +``` + +## Validation and Limits Are Identical to YAML + +Because XML lowers to the same intermediate structure and reuses the YAML element parsers, everything that applies to YAML applies to XML unchanged: + +- **`KnownProperties` validation** -- unknown attributes are flagged with the same "Did you mean?" typo suggestions. +- **`ResourceLimits`** -- `MaxFileSize`, `MaxNestingDepth`, `MaxRenderDepth` are enforced exactly as for YAML. +- **Expressions** -- `{{variable}}`, inline expressions, loops (``), and conditionals (``) behave identically. +- **Malformed input** -- malformed XML throws `TemplateParseException`, just as malformed YAML does. + +## See Also + +- [[Template-Syntax]] -- YAML template structure, canvas, units +- [[Element-Reference]] -- complete property reference for every element type +- [[Template-Expressions]] -- variables, loops, conditionals +- [[API-Reference]] -- `IFlexRender`, builder, extension methods diff --git a/llms-full.txt b/llms-full.txt index 1a4efe9..d78aa44 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -30,6 +30,10 @@ src/FlexRender.Yaml/ # YAML template parser (-> Core + YamlDotNet) TemplateParser.cs # YAML to AST parser (includes EachElement, IfElement) KnownProperties.cs # YAML property validation with Levenshtein "Did you mean?" suggestions +src/FlexRender.Xml/ # XML template parser, alternative to YAML (same Template AST) + Parsing/ + XmlTemplateParser.cs # XML to AST parser; lowers XML tree to the shared element parsers, same KnownProperties validation + ResourceLimits + src/FlexRender.Http/ # HTTP resource loader (-> Core) HttpResourceLoader.cs # Load images/fonts from HTTP/HTTPS URLs FlexRenderBuilderExtensions.cs # WithHttpLoader() extension @@ -163,6 +167,39 @@ YAML Template **Async-only API:** The entire pipeline from expansion through rendering is async. There are no synchronous `Expand()`, `Process()`, `Measure()`, `ComputeLayout()`, or `Render()` methods. All public API methods return `Task` or `Task`. This is required because `ContentElement` expansion involves async I/O (loading external content sources). +### XML as an Alternative Input + +Templates may be authored in **XML** (`FlexRender.Xml`) instead of YAML. The XML parser (`XmlTemplateParser`) produces the **same `Template` AST** as `TemplateParser` -- same element types, same properties, same `KnownProperties` validation (with "Did you mean?" suggestions), same `ResourceLimits`. XML is offered because LLMs often emit attributes more reliably than YAML indentation. Render with the `RenderXml` extension (mirrors `RenderYaml`): + +```csharp +var render = new FlexRenderBuilder().WithSkia().Build(); +byte[] png = await render.RenderXml(xml, data); + +// Parse once, reuse: +var parser = new XmlTemplateParser(); +byte[] png = await render.RenderXml(xml, data, parser: parser); +``` + +Mapping rules (XML -> same AST as YAML): + +- Single root ``; `` child for canvas; `template`/`fonts` via attributes/``; every other child of the root is a layout element. +- Element type = XML local-name: ``, ``, ``, ``, ``, ``, `
`, ``. +- Scalar properties = attributes (``); names identical to YAML (kebab-case and camelCase pass through). +- `Hello` is equivalent to `` (content attribute wins if both present; svg `content` follows the same rule). +- Layout containers: `flex`/`each` children are nested elements directly (`children`). Wrappers: `if` -> ``/``/``; `table` -> ``/``; `chart` -> ``, ``, ``/``, ``; `draw` -> ``. +- List attributes: `data="12,30,22,48"` -> number array; scatter/bubble `points="1,2; 3,4"` -> tuples; `palette="#f00,#0f0"` -> color list (or `palette="ocean"` named). + +Minimal example: + +```xml + + + + +``` + +See `docs/wiki/Xml-Syntax.md` for the full XML<->YAML mapping and a side-by-side flex+chart example. + ### Template Caching Templates can be parsed once and cached, then rendered with different data: @@ -1670,6 +1707,9 @@ public interface IFlexRender : IDisposable // Extension methods from FlexRender.Yaml: // - RenderYaml(yaml, data, format, ct) - render from YAML string // - RenderFile(path, data, format, ct) - render from YAML file + +// Extension methods from FlexRender.Xml: +// - RenderXml(xml, data, format, parser?, ct) - render from XML string (mirrors RenderYaml) ``` ### ImageFormat Enum diff --git a/llms.txt b/llms.txt index 4754b0e..09b9951 100644 --- a/llms.txt +++ b/llms.txt @@ -19,6 +19,8 @@ src/FlexRender.Core/ # Core library (0 external dependencies) src/FlexRender.Yaml/ # YAML template parser (-> Core + YamlDotNet) Parsing/ # TemplateParser +src/FlexRender.Xml/ # XML template parser, alternative to YAML (same Template AST) + Parsing/ # XmlTemplateParser src/FlexRender.Http/ # HTTP resource loader (-> Core) src/FlexRender.Skia.Render/ # SkiaSharp renderer (-> Core + SkiaSharp) Abstractions/ # ISkiaRenderer, IFontLoader, IImageLoader, IFontManager @@ -424,6 +426,8 @@ Configure via builder: `builder.WithLimits(limits => { limits.MaxRenderDepth = 2 ## Builder API +Templates may be authored in YAML (`FlexRender.Yaml`) or XML (`FlexRender.Xml`, via `XmlTemplateParser` / `RenderXml`). Both produce the same `Template` AST, with identical validation and resource limits. See `docs/wiki/Xml-Syntax.md` for the XML<->YAML mapping. + ### Without DI (Skia) ```csharp From 18ad84ef80b24a1e58ed8bc55315af9da1aab766 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:40:51 +0400 Subject: [PATCH 107/119] fix(charts): enforce MaxDataPointsPerSeries on data-bound series --- ...2026-03-10-content-options-autocomplete.md | 514 ++++++++++++++++++ ...-10-content-options-autocomplete-design.md | 62 +++ src/FlexRender.Core/FlexRender.Core.csproj | 1 + .../Parsing/Ast/ChartElement.cs | 28 +- src/FlexRender.Yaml/Parsing/ChartParsers.cs | 2 + .../Ast/ChartElementDataBindingTests.cs | 24 + .../Parsing/ChartParsersTests.cs | 39 ++ 7 files changed, 665 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-10-content-options-autocomplete.md create mode 100644 docs/superpowers/specs/2026-03-10-content-options-autocomplete-design.md diff --git a/docs/superpowers/plans/2026-03-10-content-options-autocomplete.md b/docs/superpowers/plans/2026-03-10-content-options-autocomplete.md new file mode 100644 index 0000000..a921e46 --- /dev/null +++ b/docs/superpowers/plans/2026-03-10-content-options-autocomplete.md @@ -0,0 +1,514 @@ +# Playground Improvements Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Three Playground improvements: (1) context-aware autocomplete for content `options:` block, (2) enable QR code rendering, (3) surface render errors for missing images/resources. + +**Architecture:** Extend JSON schema with NDC option definitions and wire autocomplete to use them. Add QR module reference and `.WithQr()` call. After render, check `GetLastError()` and display the message in the errors pane. + +**Tech Stack:** JSON Schema, JavaScript (ES modules), Monaco Editor API, C# (.NET 10) + +--- + +## File Structure + +- **Modify:** `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json` — add `ndcOptions` and `charsetStyle` definitions +- **Modify:** `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` — extend context detection and suggestions for options/charsets +- **Modify:** `src/FlexRender.Playground/FlexRender.Playground.csproj` — add QR module reference +- **Modify:** `src/FlexRender.Playground/PlaygroundApi.cs` — add `.WithQr()` to builder +- **Modify:** `src/FlexRender.Playground/wwwroot/main.js` — check `GetLastError()` after empty render result + +--- + +## Chunk 1: Schema Definitions + +### Task 1: Add ndcOptions and charsetStyle definitions to JSON schema + +**Files:** +- Modify: `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json` + +- [ ] **Step 1: Add `charsetStyle` definition** + +Add before the closing `}` of `"definitions"` (before line 900): + +```json +"charsetStyle": { + "description": "Per-charset style configuration for NDC content parser.", + "type": "object", + "properties": { + "font": { + "description": "Font registration name (e.g., \"bold\", \"default\").", + "type": "string" + }, + "font_family": { + "description": "Explicit font family for this charset.", + "type": "string" + }, + "font_style": { + "description": "Font style.", + "type": "string", + "enum": ["bold", "italic", "bold-italic"] + }, + "font_size": { + "description": "Explicit font size in pixels.", + "type": "integer", + "minimum": 1 + }, + "color": { + "description": "Text color (hex format, e.g., \"#333\").", + "type": "string" + }, + "encoding": { + "description": "Character encoding (e.g., \"none\", \"qwerty-jcuken\").", + "type": "string" + }, + "uppercase": { + "description": "Convert text to uppercase.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false +} +``` + +- [ ] **Step 2: Add `ndcOptions` definition** + +Add after `charsetStyle`: + +```json +"ndcOptions": { + "description": "Options for the NDC content parser.", + "type": "object", + "properties": { + "input_encoding": { + "description": "Byte encoding for binary data.", + "type": "string", + "enum": ["latin1", "iso-8859-1", "utf-8", "utf8", "ascii"], + "default": "latin1" + }, + "columns": { + "description": "Max characters per line (receipt width).", + "type": "integer", + "minimum": 1, + "default": 40 + }, + "font_family": { + "description": "Font family for all text (e.g., \"JetBrains Mono\").", + "type": "string" + }, + "char_width_ratio": { + "description": "Character width as fraction of font size for monospace fonts.", + "type": "number", + "minimum": 0.1, + "default": 0.6 + }, + "charsets": { + "description": "Per-charset style mappings keyed by designator character.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/charsetStyle" + } + } + }, + "additionalProperties": false +} +``` + +- [ ] **Step 3: Update `contentElement.properties.options` to reference `ndcOptions`** + +Replace the current `options` property in `contentElement` (line 894-897): + +```json +"options": { + "description": "Format-specific rendering options. Properties depend on the chosen format.", + "oneOf": [ + { "$ref": "#/definitions/ndcOptions" }, + { "type": "object", "description": "No options for this format.", "properties": {}, "additionalProperties": false } + ] +} +``` + +- [ ] **Step 4: Verify schema is valid JSON** + +Run: `python3 -c "import json; json.load(open('src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json'))"` +Expected: no output (valid JSON) + +- [ ] **Step 5: Commit** + +```bash +git add src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json +git commit -m "feat: add ndcOptions and charsetStyle definitions to JSON schema" +``` + +--- + +## Chunk 2: Autocomplete Context Detection + +### Task 2: Extend detectContext to recognize options and charset contexts + +**Files:** +- Modify: `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` + +The autocomplete must handle these YAML structures: + +```yaml +- type: content + format: ndc + options: # <-- context: content-options, format: ndc + input_encoding: latin1 + columns: 44 + charsets: # <-- still content-options (charsets is a key here) + I: # <-- charset designator key, not suggested + font_style: # <-- context: content-charset-item + color: "#333" +``` + +- [ ] **Step 1: Extend `detectContext` to return `content-options` context** + +Add detection for `options:` inside a content element. When walking backwards, if we encounter `options:` at a parent indent, continue walking to find `type: content` and `format:` value. Return `{ type: 'content-options', format: 'ndc' }`. + +In `detectContext`, add this check after the `if (trimmed === 'canvas:')` block (around line 150): + +```javascript +if (trimmed === 'options:') { + // Walk further back to find the parent content element and its format + const parentInfo = findContentParent(lines, i, lineIndent); + if (parentInfo) { + return { type: 'content-options', format: parentInfo.format }; + } +} +``` + +- [ ] **Step 2: Detect `content-charset-item` context** + +When inside a charset entry (two indentation levels below `charsets:`), detect that we're editing charset style properties. Add to `detectContext` right after the `options:` check: + +```javascript +// Check if we're inside a charsets > designator block +if (lineIndent < currentIndent) { + // Look for pattern: grandparent is "charsets:", parent is a designator key + const isDesignatorKey = trimmed.match(/^\w+:$/); + if (isDesignatorKey) { + // Check if grandparent is "charsets:" inside content options + for (let k = i - 1; k >= 0; k--) { + const prev = lines[k]; + const prevTrimmed = prev.trim(); + if (!prevTrimmed || prevTrimmed.startsWith('#')) continue; + const prevIndent = prev.match(/^(\s*)/)[1].length; + if (prevIndent < lineIndent) { + if (prevTrimmed === 'charsets:') { + return { type: 'content-charset-item' }; + } + break; + } + } + } +} +``` + +- [ ] **Step 3: Add `findContentParent` helper function** + +Add after `detectContext`: + +```javascript +function findContentParent(lines, fromIndex, optionsIndent) { + let format = null; + for (let i = fromIndex - 1; i >= 0; i--) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const indent = line.match(/^(\s*)/)[1].length; + if (indent >= optionsIndent) { + // Sibling or child of options — check for format: + const formatMatch = trimmed.match(/^format:\s*(\w+)/); + if (formatMatch) format = formatMatch[1]; + continue; + } + // Parent level — should be the element with type: content + const typeMatch = trimmed.match(/^-?\s*type:\s*(\w+)/); + if (typeMatch) { + if (typeMatch[1] === 'content') return { format }; + return null; // Not a content element + } + // If we hit a sibling property at element level, keep scanning for type + if (indent < optionsIndent) { + const formatMatch = trimmed.match(/^-?\s*format:\s*(\w+)/); + if (formatMatch) format = formatMatch[1]; + } + if (indent === 0) break; // Reached root + } + return null; +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs +git commit -m "feat: detect content-options and charset-item contexts in autocomplete" +``` + +--- + +## Chunk 3: Wire Suggestions to Schema Definitions + +### Task 3: Suggest properties and values for content options + +**Files:** +- Modify: `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` + +- [ ] **Step 1: Handle `content-options` and `content-charset-item` in the completion provider switch** + +In the `provideCompletionItems` switch block (after `case 'template':`, around line 129), add: + +```javascript +case 'content-options': { + const formatDef = context.format + ? defs[context.format + 'Options'] + : null; + const props = formatDef?.properties || {}; + return makeSuggestions(monaco, props, range, 'content-options'); +} +case 'content-charset-item': { + const props = defs.charsetStyle?.properties || {}; + return makeSuggestions(monaco, props, range, 'content-charset-item'); +} +``` + +- [ ] **Step 2: Update `findPropertyDef` to resolve options properties** + +In `findPropertyDef` (around line 295), add context detection for options and charset blocks. Before the flex item fallback (line 316), add: + +```javascript +// Check if inside content options or charset item +const optionsContext = detectContentOptionsContext(lines); +if (optionsContext === 'charset-item') { + const charsetDef = defs.charsetStyle; + if (charsetDef?.properties?.[cleanKey]) return charsetDef.properties[cleanKey]; +} else if (optionsContext) { + // optionsContext is the format name + const optDef = defs[optionsContext + 'Options']; + if (optDef?.properties?.[cleanKey]) return optDef.properties[cleanKey]; +} +``` + +- [ ] **Step 3: Add `detectContentOptionsContext` helper** + +Add after `findPropertyDef`: + +```javascript +function detectContentOptionsContext(lines) { + let inOptions = false; + let optionsIndent = -1; + let charsetsIndent = -1; + + for (let i = lines.length - 2; i >= 0; i--) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const indent = line.match(/^(\s*)/)[1].length; + + // Check if we're inside a charset designator block + if (charsetsIndent < 0 && trimmed.match(/^\w+:$/) && !inOptions) { + // Possible designator — check parent + for (let k = i - 1; k >= 0; k--) { + const prev = lines[k]; + const prevTrimmed = prev.trim(); + if (!prevTrimmed || prevTrimmed.startsWith('#')) continue; + const prevIndent = prev.match(/^(\s*)/)[1].length; + if (prevIndent < indent && prevTrimmed === 'charsets:') { + return 'charset-item'; + } + if (prevIndent < indent) break; + } + } + + if (trimmed === 'options:') { + // Find format from sibling properties + const parentInfo = findContentParent(lines, i, indent); + return parentInfo?.format || null; + } + + if (indent === 0) break; + } + return null; +} +``` + +- [ ] **Step 4: Update `suggestValues` to resolve enum values for options properties** + +The existing logic in `suggestValues` already calls `findPropertyDef` which will now return the correct property definition with enum values (e.g., `input_encoding` enum, `font_style` enum, `uppercase` boolean). No changes needed — just verify it works. + +- [ ] **Step 5: Add sort order for content-options context** + +In `getSortOrder` (line 363), add entries for the new contexts: + +```javascript +'content-options': { input_encoding: '0', columns: '1', font_family: '2', char_width_ratio: '3', charsets: '4' }, +'content-charset-item': { font: '0', font_family: '1', font_style: '2', font_size: '3', color: '4', encoding: '5', uppercase: '6' }, +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs +git commit -m "feat: wire content options and charset suggestions to schema definitions" +``` + +--- + +## Chunk 4: Manual Verification + +### Task 4: Verify in browser + +- [ ] **Step 1: Start dev server and test** + +Run: `dotnet run --project src/FlexRender.Playground` + +Open browser, create a template with: + +```yaml +layout: + - type: content + source: "test.ndc" + format: ndc + options: + | <-- trigger autocomplete here, expect: input_encoding, columns, font_family, char_width_ratio, charsets +``` + +Verify: +1. Inside `options:` with `format: ndc` → suggests NDC options +2. After `input_encoding:` → suggests `latin1`, `iso-8859-1`, `utf-8`, `utf8`, `ascii` +3. Inside `charsets: > X:` → suggests charset style properties (`font`, `font_style`, etc.) +4. After `font_style:` → suggests `bold`, `italic`, `bold-italic` +5. After `uppercase:` → suggests `true`, `false` +6. Hovering over option keys shows descriptions +7. With `format: markdown` → `options:` suggests nothing (empty properties) +8. Without `format:` → `options:` suggests nothing + +- [ ] **Step 2: Verify publish output** + +Run: `dotnet publish src/FlexRender.Playground -c Release -o /tmp/playground-verify` +Expected: builds without errors, schema included in output + +- [ ] **Step 3: Final commit if any fixes needed** + +--- + +## Chunk 5: Enable QR Code Rendering + +### Task 5: Add QR module to Playground + +**Files:** +- Modify: `src/FlexRender.Playground/FlexRender.Playground.csproj` +- Modify: `src/FlexRender.Playground/PlaygroundApi.cs` + +- [ ] **Step 1: Add QR project reference to Playground csproj** + +In `FlexRender.Playground.csproj`, add to the `` with other ProjectReferences: + +```xml + +``` + +Note: Reference `FlexRender.QrCode.Skia.Render` directly (not the meta-package `FlexRender.QrCode` which also pulls in ImageSharp and Svg renderers that aren't needed in WASM). + +- [ ] **Step 2: Add `using` and `.WithQr()` call in PlaygroundApi.cs** + +Add using directive at top of `PlaygroundApi.cs`: + +```csharp +using FlexRender.QrCode; +``` + +Change the builder initialization (line 43-45) from: + +```csharp +var builder = new FlexRenderBuilder() + .WithNdc() + .WithSkia(); +``` + +To: + +```csharp +var builder = new FlexRenderBuilder() + .WithNdc() + .WithSkia(skia => skia.WithQr()); +``` + +- [ ] **Step 3: Verify it builds** + +Run: `dotnet build src/FlexRender.Playground/FlexRender.Playground.csproj` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add src/FlexRender.Playground/FlexRender.Playground.csproj src/FlexRender.Playground/PlaygroundApi.cs +git commit -m "feat: enable QR code rendering in Playground" +``` + +--- + +## Chunk 6: Surface Render Errors for Missing Images + +### Task 6: Show GetLastError() in the errors pane when render returns empty + +**Files:** +- Modify: `src/FlexRender.Playground/wwwroot/main.js` + +Currently when `RenderToPng` catches an exception, it stores the error in `_lastError` and returns an empty byte array. The JS side shows "Render returned empty — check console" but never calls `GetLastError()`. + +- [ ] **Step 1: After empty render result, check GetLastError()** + +In `main.js`, find the block that handles empty render result (around line 1137-1139): + +```javascript +} else { + statusText.textContent = 'Render returned empty \u2014 check console'; +} +``` + +Replace with: + +```javascript +} else { + const lastError = api.GetLastError(); + if (lastError) { + errorsPane.textContent = lastError; + statusBar.classList.add('error'); + statusText.textContent = 'Error'; + switchToTab('errors'); + } else { + statusText.textContent = 'Render returned empty'; + } +} +``` + +- [ ] **Step 2: Verify locally** + +Run: `dotnet run --project src/FlexRender.Playground` + +Create a template referencing a non-existent image: + +```yaml +canvas: + width: 200 + height: 200 +layout: + - type: image + src: "nonexistent.png" + width: 100 + height: 100 +``` + +Expected: Error message appears in the Errors tab instead of silent empty render. + +- [ ] **Step 3: Commit** + +```bash +git add src/FlexRender.Playground/wwwroot/main.js +git commit -m "feat: surface render errors for missing images in Playground" +``` diff --git a/docs/superpowers/specs/2026-03-10-content-options-autocomplete-design.md b/docs/superpowers/specs/2026-03-10-content-options-autocomplete-design.md new file mode 100644 index 0000000..3de6074 --- /dev/null +++ b/docs/superpowers/specs/2026-03-10-content-options-autocomplete-design.md @@ -0,0 +1,62 @@ +# Content Options Autocomplete + +Context-aware YAML autocomplete for `options:` block inside `type: content` elements, based on the `format:` value. + +## Scope + +Changes limited to two files: +- `src/FlexRender.Playground/wwwroot/schemas/flexrender-template.json` — add option definitions per format +- `src/FlexRender.Playground/wwwroot/yaml-autocomplete.mjs` — detect options/charsets context, suggest properties from schema + +## Schema Changes + +Add definitions to `flexrender-template.json`: + +### `ndcOptions` +| Property | Type | Enum/Description | +|---|---|---| +| `input_encoding` | string | `latin1`, `iso-8859-1`, `utf-8`, `utf8`, `ascii` | +| `columns` | integer | Max characters per line (default 40) | +| `font_family` | string | Font family for all text | +| `char_width_ratio` | number | Character width as fraction of font size (default 0.6) | +| `charsets` | object | Map of charset designator to `charsetStyle` | + +### `charsetStyle` +| Property | Type | Enum/Description | +|---|---|---| +| `font` | string | Font registration name | +| `font_family` | string | Explicit font family | +| `font_style` | string | `bold`, `italic`, `bold-italic` | +| `font_size` | integer | Font size in pixels | +| `color` | string | Text color (hex) | +| `encoding` | string | Character encoding | +| `uppercase` | boolean | Convert to uppercase | + +### `markdownOptions` / `htmlOptions` +Empty object definitions — no options supported. + +### Schema wiring +`contentElement.properties.options` gains `oneOf` referencing `ndcOptions`, `markdownOptions`, `htmlOptions`. The autocomplete code uses the `format` value to pick the right definition at runtime. + +## Autocomplete Changes + +### Context detection (`detectContext`) +Extend to recognize: +- **`content-options`** — cursor inside `options:` block of a content element. Carry the `format` value. +- **`content-charset-item`** — cursor inside a charset entry under `charsets:` (two levels deep inside options). + +### Property suggestions +- In `content-options` context: look up `{format}Options` definition from schema, suggest its properties. +- In `content-charset-item` context: suggest `charsetStyle` properties from schema. +- After colon: suggest enum values where defined (`input_encoding`, `font_style`), booleans for `uppercase`. + +### Value suggestions +- `format:` already suggests `ndc`, `markdown`, `html` from existing enum — no change needed. +- `input_encoding:` → enum values from schema +- `font_style:` → enum values from schema +- `uppercase:` → `true` / `false` + +## Out of Scope +- No changes to C# code, parsers, or KnownProperties +- No changes to markdown/html parsers (they have no options) +- No validation/warning when wrong options are used for a format diff --git a/src/FlexRender.Core/FlexRender.Core.csproj b/src/FlexRender.Core/FlexRender.Core.csproj index 32a2f97..c28a1de 100644 --- a/src/FlexRender.Core/FlexRender.Core.csproj +++ b/src/FlexRender.Core/FlexRender.Core.csproj @@ -8,6 +8,7 @@ + diff --git a/src/FlexRender.Core/Parsing/Ast/ChartElement.cs b/src/FlexRender.Core/Parsing/Ast/ChartElement.cs index faef8ad..cfd2b05 100644 --- a/src/FlexRender.Core/Parsing/Ast/ChartElement.cs +++ b/src/FlexRender.Core/Parsing/Ast/ChartElement.cs @@ -83,6 +83,14 @@ public ChartElement(ChartType chartType, IReadOnlyList series) /// Gets or sets whether the heatmap draws each cell's numeric value inside the cell. public bool ShowCellValues { get; set; } + /// + /// Gets or sets the maximum number of data points allowed in a single resolved series. Enforced + /// when a data-bound series is resolved during , mirroring the + /// limit the inline parse path applies. Defaults to (no limit) so + /// directly-constructed charts are unaffected unless the parser sets it. + /// + internal int MaxDataPointsPerSeries { get; set; } = int.MaxValue; + /// public override TemplateElement CloneWithSubstitution(Func substitutor) { @@ -105,7 +113,8 @@ public override TemplateElement CloneWithSubstitution(Func sub ValueLabel = ValueLabel, XLabels = XLabels, YLabels = YLabels, - ShowCellValues = ShowCellValues + ShowCellValues = ShowCellValues, + MaxDataPointsPerSeries = MaxDataPointsPerSeries }; CopyBasePropertiesTo(clone, substitutor); return clone; @@ -147,7 +156,7 @@ public override void ResolveExpressions(Func resolv var path = StripBraces(series.DataExpression); var value = ExpressionEvaluator.Resolve(path, context); - resolved.Add(series.WithData(ConvertToDoubles(value, series.Label))); + resolved.Add(series.WithData(ConvertToDoubles(value, series.Label, MaxDataPointsPerSeries))); } SetSeries(resolved); @@ -173,19 +182,28 @@ private static string StripBraces(string expression) /// /// Converts a resolved of numbers to a double array. A non-array /// (e.g. a missing path resolving to ) yields an empty array; a - /// non-numeric element raises a clear template error naming the series. + /// non-numeric element raises a clear template error naming the series; an array longer than + /// raises an error mirroring the inline parse-path limit. /// /// The resolved template value. /// The series label, used in error messages. + /// The maximum number of data points allowed in the resolved series. /// The numeric values (possibly empty). - /// Thrown when an array element is not numeric. - private static double[] ConvertToDoubles(TemplateValue value, string? label) + /// Thrown when an array element is not numeric or the data-point limit is exceeded. + private static double[] ConvertToDoubles(TemplateValue value, string? label, int maxDataPoints) { if (value is not ArrayValue array) { return Array.Empty(); } + if (array.Count > maxDataPoints) + { + throw new TemplateEngineException( + $"Chart series '{label ?? "(unlabeled)"}' resolved to {array.Count} data points, " + + $"exceeding the maximum of {maxDataPoints}."); + } + var result = new double[array.Count]; for (var i = 0; i < array.Count; i++) { diff --git a/src/FlexRender.Yaml/Parsing/ChartParsers.cs b/src/FlexRender.Yaml/Parsing/ChartParsers.cs index 1352ff9..b805ded 100644 --- a/src/FlexRender.Yaml/Parsing/ChartParsers.cs +++ b/src/FlexRender.Yaml/Parsing/ChartParsers.cs @@ -52,6 +52,8 @@ internal static TemplateElement ParseChartElement(YamlMappingNode node, int maxS Margin = GetExprStringValue(node, "margin", "0") }; + chart.MaxDataPointsPerSeries = maxDataPoints; + ElementParsers.ApplyFlexItemProperties(node, chart); return chart; } diff --git a/tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs b/tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs index 7fc9956..d99057b 100644 --- a/tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs +++ b/tests/FlexRender.Tests/Parsing/Ast/ChartElementDataBindingTests.cs @@ -80,4 +80,28 @@ public void ResolveExpressions_NonNumericArrayItem_Throws() var ex = Assert.Throws(() => chart.ResolveExpressions(PassthroughResolver, data)); Assert.Contains("Sales", ex.Message); } + + [Fact] + public void ResolveExpressions_BoundSeries_ExceedingMaxDataPoints_Throws() + { + var chart = new ChartElement(ChartType.Bar, new List + { + ChartSeries.FromExpression("Sales", "{{ sales }}") + }) + { + MaxDataPointsPerSeries = 2 + }; + + var data = new ObjectValue + { + ["sales"] = new ArrayValue(new TemplateValue[] + { + new NumberValue(1m), new NumberValue(2m), new NumberValue(3m) + }) + }; + + var ex = Assert.Throws(() => chart.ResolveExpressions(PassthroughResolver, data)); + Assert.Contains("maximum", ex.Message); + Assert.Contains("Sales", ex.Message); + } } diff --git a/tests/FlexRender.Tests/Parsing/ChartParsersTests.cs b/tests/FlexRender.Tests/Parsing/ChartParsersTests.cs index 0540901..1c28e03 100644 --- a/tests/FlexRender.Tests/Parsing/ChartParsersTests.cs +++ b/tests/FlexRender.Tests/Parsing/ChartParsersTests.cs @@ -1,6 +1,9 @@ +using FlexRender; using FlexRender.Charts; +using FlexRender.Configuration; using FlexRender.Parsing; using FlexRender.Parsing.Ast; +using FlexRender.TemplateEngine; using Xunit; namespace FlexRender.Tests.Parsing; @@ -134,4 +137,40 @@ public void Parse_UnknownChartType_Throws() var ex = Assert.Throws(() => _parser.Parse(yaml)); Assert.Contains("chart-type", ex.Message); } + + [Fact] + public void Parse_BoundSeriesResolvingOverLimit_ThrowsOnResolveExpressions() + { + var limits = new ResourceLimits { MaxDataPointsPerSeries = 2 }; + var parser = new TemplateParser(limits); + + const string yaml = """ + canvas: + width: 600 + layout: + - type: chart + chart-type: bar + width: 600 + height: 300 + series: + - label: Big + data: "{{ big }}" + """; + + var template = parser.Parse(yaml); + var chart = Assert.IsType(template.Elements[0]); + + var data = new ObjectValue + { + ["big"] = new ArrayValue(new TemplateValue[] + { + new NumberValue(1m), new NumberValue(2m), new NumberValue(3m) + }) + }; + + var ex = Assert.Throws( + () => chart.ResolveExpressions(static (raw, _) => raw, data)); + Assert.Contains("maximum", ex.Message); + Assert.Contains("Big", ex.Message); + } } From bb2f9f0376ce697c8df2abce2c68bd152e7f2a18 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:41:41 +0400 Subject: [PATCH 108/119] refactor(renderer): remove dead default branch in chart DrawSeries --- .../Rendering/ChartRenderer.cs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index ee4c85b..8acbaa3 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -133,8 +133,8 @@ private static void DrawNoData(SKCanvas canvas, ChartTheme theme, float width, f } /// - /// Draws series geometry by chart type. Bar charts render grid, axes, and columns; other - /// chart types fall through to a faint plot border until added in later tasks. + /// Draws series geometry by chart type. Every has a dedicated drawing + /// routine (bars, line/area, pie/donut, sparkline, scatter/bubble, gauge/progress, heatmap, radar). /// private static void DrawSeries( SKCanvas canvas, @@ -184,17 +184,7 @@ private static void DrawSeries( DrawRadar(canvas, chart, theme, width, height, typeface, antialias); break; default: - // Other chart types are added in later tasks. - using (var border = new SKPaint - { - Color = ColorParser.Parse(theme.AxisColor), - Style = SKPaintStyle.Stroke, - StrokeWidth = 1f, - IsAntialias = antialias - }) - { - canvas.DrawRect(0.5f, 0.5f, width - 1f, height - 1f, border); - } + // Enum-exhaustiveness guard: all defined ChartType members have explicit cases above. break; } } From 2067eecdac823b03ea796b84cbbe960b935b6624 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sat, 13 Jun 2026 23:42:50 +0400 Subject: [PATCH 109/119] fix(xml): reject malformed else-if instead of silently dropping --- src/FlexRender.Xml/XmlToYamlNodeConverter.cs | 9 ++- .../Parsing/Xml/XmlControlFlowTests.cs | 61 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/FlexRender.Xml/XmlToYamlNodeConverter.cs b/src/FlexRender.Xml/XmlToYamlNodeConverter.cs index e9c8230..55a50e3 100644 --- a/src/FlexRender.Xml/XmlToYamlNodeConverter.cs +++ b/src/FlexRender.Xml/XmlToYamlNodeConverter.cs @@ -202,11 +202,14 @@ private static void AddWrapper(YamlMappingNode node, string parentType, XElement node.Add(name, ConvertElementSequence(wrapper)); break; case "else-if": - var inner = wrapper.Elements().FirstOrDefault(); - if (inner is not null) + var children = wrapper.Elements().ToList(); + if (children.Count != 1 + || !string.Equals(children[0].Name.LocalName, "if", StringComparison.Ordinal)) { - node.Add("elseIf", ConvertElement(inner)); + throw new TemplateParseException( + "An must contain exactly one child element."); } + node.Add("elseIf", ConvertElement(children[0])); break; case "columns": node.Add("columns", ConvertAttributeItemSequence(wrapper)); diff --git a/tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs b/tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs index 542f9ee..1224099 100644 --- a/tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs +++ b/tests/FlexRender.Tests/Parsing/Xml/XmlControlFlowTests.cs @@ -1,3 +1,4 @@ +using FlexRender.Parsing; using FlexRender.Parsing.Ast; using FlexRender.Xml; using Xunit; @@ -78,4 +79,64 @@ public void Parse_If_ElseIf() Assert.NotNull(ifEl.ElseIf); Assert.Equal("warm", ifEl.ElseIf!.CompareValue); } + + [Fact] + public void Parse_ElseIf_WithNoChild_Throws() + { + const string xml = """ + + + + + + + + """; + + var ex = Assert.Throws(() => _parser.Parse(xml)); + Assert.Contains("else-if", ex.Message); + } + + [Fact] + public void Parse_ElseIf_WithNonIfChild_Throws() + { + const string xml = """ + + + + + + + + + + """; + + var ex = Assert.Throws(() => _parser.Parse(xml)); + Assert.Contains("else-if", ex.Message); + } + + [Fact] + public void Parse_ElseIf_WithMultipleChildren_Throws() + { + const string xml = """ + + + + + + + + + + + + + + + """; + + var ex = Assert.Throws(() => _parser.Parse(xml)); + Assert.Contains("else-if", ex.Message); + } } From 18a0d88ba120fb36b6a580b605185e8ebc47fc80 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sun, 14 Jun 2026 06:55:38 +0400 Subject: [PATCH 110/119] fix(charts): reserve top headroom so axis labels and bars are not clipped ComputePlotArea now accepts a topGutter parameter reserved at the top of the plot regardless of whether a title is present. The topmost y-axis tick label is drawn centered at plot.Top, so with no top headroom its upper half was clipped against the chart box top edge; the tallest bar/line point hit the same edge. Axis-based renderers (bars, line/area, scatter/bubble) now pass topGutter = theme.LabelSize * 0.6f to clear half the top tick label. Regenerated affected chart goldens and their visual-docs mirror copies. --- .../visual-docs/output/chart-area-light.png | Bin 12413 -> 13467 bytes .../visual-docs/output/chart-bar-light.png | Bin 8073 -> 8084 bytes .../visual-docs/output/chart-bubble-light.png | Bin 7056 -> 7352 bytes .../visual-docs/output/chart-line-minimal.png | Bin 16537 -> 17722 bytes .../output/chart-scatter-light.png | Bin 4695 -> 4927 bytes src/FlexRender.Core/Charts/ChartLayout.cs | 11 ++++-- .../Rendering/ChartRenderer.cs | 9 +++-- .../Charts/ChartLayoutMathTests.cs | 32 ++++++++++++++---- .../Snapshots/golden/chart_area_light.png | Bin 12413 -> 13467 bytes .../golden/chart_bar_horizontal_dark.png | Bin 2361 -> 2361 bytes .../Snapshots/golden/chart_bar_light.png | Bin 8073 -> 8084 bytes .../Snapshots/golden/chart_bubble_light.png | Bin 7056 -> 7352 bytes .../Snapshots/golden/chart_line_minimal.png | Bin 16537 -> 17722 bytes .../Snapshots/golden/chart_scatter_light.png | Bin 4695 -> 4927 bytes 14 files changed, 40 insertions(+), 12 deletions(-) diff --git a/examples/visual-docs/output/chart-area-light.png b/examples/visual-docs/output/chart-area-light.png index ca6ea1b3329dd5f828caf5aafc474c11ab74f6fa..50f41673cb0ee3b67b77af1fb74aa0f16e3b309c 100644 GIT binary patch literal 13467 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 C?8K@7 literal 8073 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 l_}QPuWKG$I|6SX$%yM0Nqz%ADdJ<- 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 literal 7056 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 G+y4Uj@4=1$ diff --git a/examples/visual-docs/output/chart-line-minimal.png b/examples/visual-docs/output/chart-line-minimal.png index a75a8b0910950466cf67126d87ad93141f0b99b9..b5f7360048474f5176157b74aab7fcbcb92c8dad 100644 GIT binary patch literal 17722 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 literal 16537 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 C)&SxF diff --git a/examples/visual-docs/output/chart-scatter-light.png b/examples/visual-docs/output/chart-scatter-light.png index 5dc9dd83d15329024df5c195b2fbcf0548a7afb5..d317d9a3eda17711726a24c99addbc3450afe8ba 100644 GIT binary patch literal 4927 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 McR9xV#{cp^0GCm7b^rhX diff --git a/src/FlexRender.Core/Charts/ChartLayout.cs b/src/FlexRender.Core/Charts/ChartLayout.cs index f924a3b..119ff46 100644 --- a/src/FlexRender.Core/Charts/ChartLayout.cs +++ b/src/FlexRender.Core/Charts/ChartLayout.cs @@ -35,6 +35,12 @@ public static class ChartLayout /// The bottom gutter reserved for x-axis labels. /// The title band height when is true. /// The legend band size (height for top/bottom, width for left/right). + /// + /// Headroom reserved at the very top of the plot, regardless of whether a title is present. + /// This clears the upper half of the topmost y-axis tick label (drawn centered at the plot top) + /// and gives the tallest bar or line point breathing room so neither is clipped against the + /// chart box top edge. + /// /// The computed , never inverted. public static PlotArea ComputePlotArea( float width, @@ -44,10 +50,11 @@ public static PlotArea ComputePlotArea( float axisGutterLeft, float axisGutterBottom, float titleHeight, - float legendExtent) + float legendExtent, + float topGutter = 0f) { var left = axisGutterLeft; - var top = hasTitle ? titleHeight : 0f; + var top = (hasTitle ? titleHeight : 0f) + topGutter; // Mirror the left gutter on the right so series geometry and edge labels are not clipped. var right = width - axisGutterLeft; var bottom = height - axisGutterBottom; diff --git a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs index 8acbaa3..aa62143 100644 --- a/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs +++ b/src/FlexRender.Skia.Render/Rendering/ChartRenderer.cs @@ -355,7 +355,8 @@ private static void DrawBars( var hasTitle = !string.IsNullOrEmpty(chart.Title); var plot = ChartLayout.ComputePlotArea( width, height, hasTitle, chart.Legend, - axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f); + axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f, + topGutter: theme.LabelSize * 0.6f); var palette = chart.Palette ?? ChartPalettes.Default; @@ -550,7 +551,8 @@ private static void DrawLineOrArea( var hasTitle = !string.IsNullOrEmpty(chart.Title); var plot = ChartLayout.ComputePlotArea( width, height, hasTitle, chart.Legend, - axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f); + axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f, + topGutter: theme.LabelSize * 0.6f); DrawGridAndYAxis(canvas, theme, plot, scale, typeface, antialias); @@ -683,7 +685,8 @@ private static void DrawScatterOrBubble( var hasTitle = !string.IsNullOrEmpty(chart.Title); var plot = ChartLayout.ComputePlotArea( width, height, hasTitle, chart.Legend, - axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f); + axisGutterLeft: 44f, axisGutterBottom: 22f, titleHeight: theme.TitleSize + 8f, legendExtent: 28f, + topGutter: theme.LabelSize * 0.6f); DrawGridAndYAxis(canvas, theme, plot, yScale, typeface, antialias); diff --git a/tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs b/tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs index 2d2e62d..1c153be 100644 --- a/tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs +++ b/tests/FlexRender.Tests/Charts/ChartLayoutMathTests.cs @@ -14,10 +14,11 @@ public void ComputePlotArea_NoTitleNoLegend_LeavesAxisGutter() var plot = ChartLayout.ComputePlotArea( width: 600f, height: 300f, hasTitle: false, legend: LegendPosition.None, - axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f, + topGutter: 8f); Assert.Equal(40f, plot.Left); - Assert.Equal(0f, plot.Top); + Assert.Equal(8f, plot.Top); Assert.Equal(600f - 40f, plot.Right); Assert.Equal(300f - 24f, plot.Bottom); } @@ -28,9 +29,23 @@ public void ComputePlotArea_WithTitle_ReservesTopBand() var plot = ChartLayout.ComputePlotArea( width: 600f, height: 300f, hasTitle: true, legend: LegendPosition.None, - axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f, + topGutter: 8f); - Assert.Equal(24f, plot.Top); + Assert.Equal(24f + 8f, plot.Top); + } + + [Fact] + public void ComputePlotArea_NoTitleWithTopGutter_ReservesHeadroom() + { + const float topGutter = 10f; + var plot = ChartLayout.ComputePlotArea( + width: 600f, height: 300f, + hasTitle: false, legend: LegendPosition.None, + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f, + topGutter: topGutter); + + Assert.True(plot.Top >= topGutter); } [Fact] @@ -39,7 +54,8 @@ public void ComputePlotArea_BottomLegend_ReservesBottomBand() var plot = ChartLayout.ComputePlotArea( width: 600f, height: 300f, hasTitle: false, legend: LegendPosition.Bottom, - axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f, + topGutter: 8f); Assert.Equal(300f - 24f - 60f, plot.Bottom); } @@ -50,7 +66,8 @@ public void ComputePlotArea_RightLegend_ReservesRightBand() var plot = ChartLayout.ComputePlotArea( width: 600f, height: 300f, hasTitle: false, legend: LegendPosition.Right, - axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f, + topGutter: 8f); Assert.Equal(600f - 40f - 60f, plot.Right); } @@ -61,7 +78,8 @@ public void ComputePlotArea_DegenerateSize_DoesNotInvert() var plot = ChartLayout.ComputePlotArea( width: 30f, height: 20f, hasTitle: true, legend: LegendPosition.Bottom, - axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f); + axisGutterLeft: 40f, axisGutterBottom: 24f, titleHeight: 24f, legendExtent: 60f, + topGutter: 8f); Assert.True(plot.Right >= plot.Left); Assert.True(plot.Bottom >= plot.Top); diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_area_light.png b/tests/FlexRender.Tests/Snapshots/golden/chart_area_light.png index ca6ea1b3329dd5f828caf5aafc474c11ab74f6fa..50f41673cb0ee3b67b77af1fb74aa0f16e3b309c 100644 GIT binary patch literal 13467 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 delta 1112 zcmZ9LdrVVj7{WxmHFSbo*KydS!C=9k<}T z=W6NOPnbmyc_kY|uh=oS-egxrxhkXLls7nvu`mrR{3QRK>B!Zmf;RQ`tdgGgDX1}` zpy%Uw0Din7Z~?&68$Cwfl^Z1DOm3U#Tuw2ST1LCGenI-+xri!6mw=Vu=hStnV-gJ% z;O#{$`#-Rr8?Upf{hAbu(j4NsA7LfV;9lS`%_Mg)k9ABS4?bM!Su4ag07*pqYs$`{ zT4$C93ry5Tv&ThQC~Aw~8Z&|7wg(MEe8 z+fz?!Q?$OkHXJA zDsXA7eYGeK@vHa-HEBbW{nOiw%?he?1gg|=IuiYSbi5XlCl<6fkBe|PG8waUt-$!M zfGCpRVs71`207Jxsxn@#7XH8%J4)iet=D_UkC;}H$nT^X@P%f=&a+xgO&9Q9Osn6d zoe*kki%oxF@7;|P`6XO6R_&5VKbh^ZkBa$b9uLu+p# zU2j>xn8-B*scHao;Qx6`_Q~h8#0rwo4;9P8^mj%@mG3@Q&JLy6>Y=xiYVAPl9NNu@ zMHeXa++mEiZ+1YObdY5lV1OKbacU{jl+dim*lP0VGivII?ruTmrTys~I>_o#l&SE8 zhV_JU6<5FwE+)MsZ^1ntEad*Sd^*l*KJ*!9dYtTh2G1n7kc>bV5mpNQv4@X1DMh?_ zSw{&I{1r9&5Phum&4>}w!4`Cw5spe21##_8O8=mG+tG~H_QJU&@}oziwyrlElUdfz z;FK9+i1Z*^tG=}KlI_X0onzAN8KY)(Y1X(py;y+74v3ogdoQRW0boYMtRq0uAA?G0 KGIYD+roRFHbR$s! diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_bar_light.png b/tests/FlexRender.Tests/Snapshots/golden/chart_bar_light.png index b815b83d527b34c000ab4d382d34fc9dca565356..9f2ecc349d08be9376003ab45ea5ca771119ae1b 100644 GIT binary patch literal 8084 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 C?8K@7 literal 8073 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 l_}QPuWKG$I|6SX$%yM0Nqz%ADdJ<- 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 literal 7056 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 G+y4Uj@4=1$ diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_line_minimal.png b/tests/FlexRender.Tests/Snapshots/golden/chart_line_minimal.png index a75a8b0910950466cf67126d87ad93141f0b99b9..b5f7360048474f5176157b74aab7fcbcb92c8dad 100644 GIT binary patch literal 17722 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 literal 16537 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 C)&SxF diff --git a/tests/FlexRender.Tests/Snapshots/golden/chart_scatter_light.png b/tests/FlexRender.Tests/Snapshots/golden/chart_scatter_light.png index 5dc9dd83d15329024df5c195b2fbcf0548a7afb5..d317d9a3eda17711726a24c99addbc3450afe8ba 100644 GIT binary patch literal 4927 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 McR9xV#{cp^0GCm7b^rhX From 4b725c8f815ad0b7ca71c8f3d72243b8215932d4 Mon Sep 17 00:00:00 2001 From: Mikhail Korolev Date: Sun, 14 Jun 2026 06:56:48 +0400 Subject: [PATCH 111/119] docs(wiki): re-sync chart doc image mirrors with current goldens --- .../visual-docs/output/chart-donut-dark.png | Bin 13095 -> 15727 bytes .../visual-docs/output/chart-pie-light.png | Bin 11494 -> 13650 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/examples/visual-docs/output/chart-donut-dark.png b/examples/visual-docs/output/chart-donut-dark.png index 97fed3d75a86e36f6a13fd13aae44fec04006f8c..c2efcf984f5aa2923b78cc63bd1db5692d708e7d 100644 GIT binary patch literal 15727 zcmd6O^;cU#*KTlkmq2hW4u#+l+*{nWKyfSX?vPSSk>XU4Vg*_piWdn`D8)S#m*Vmr z-uJtI!2RW}%UW5ibu!sDb7uBD``L-Q+A0LNw74J;h(Jvh_6!6<$pAi1*qFeRz_98T z;2*k=f|@=y@E3w@p8x_uKx!~~{eb-aMgM&LU)?fC+fB}9FM>UMW6D{$qcHSgR;|_= z(;s*G&!|JI6ZOdcy?v@z>I^GQR8CD4)q|x{h~iFG*4FOv&HFpPx^BO?7(ktzD=2s; z?0;~27jg?4+53rBfAjO(_JU7YZZ0hu3 zSo5~K`nMeYX;**%Ea2NELUiSW6wEFrC|N7_lssI*)cR1FTwb$^C#$b~{uozT zPkS)@$3k|dOV1ov4IXz-nuMyRw{~sLLb=cX+6jHDhLI$>Cne4X#uCA>&(Fw=XB(Np zHmFUt@awCmfqt*W*P8*_mf_0c7W0yIFudwz#AlS6Dzvb15iJO99Le8k62cyQ4C@%w zyK)746kcL=Svb0pl4yNgYK-IW!F$GCN<4iY9M&B;Qp7(=osS(TK=eg_477?WF?wp*bQ<7RqWSf%<*lyfdn?$y%!becNO zpB)U;q0$p~(s!qPu&6pYRcaM4nv;sL2>)UH4Xs?lKA#l_hQAz@v@1`d)}BPY%oT_Z zLxxHV+%5|A$|H^kD*!#u($=1lGlUD_I*!Vpe=LO!)Um3;A%WhIVDE@EZfb-;3ZUek zfjZK%O8};FLqzON-5HX$##DB8wtEZ=nV^K=m^PO& z*pr&LgWVX!JPOMS8913Y4MV|egh80W!7)EQYF#I)N&+@ejFLf)@zPQ-!w6rPUaqm7 zCayEtzxTO0AXFS46J__J^tMeSL%7M(mzEZFXeYAN8$Cr8JIE2wcF}D`lY#&fF9s7f zNzeJ-2Rc0d4f|l27U9JiX&;a0Z352f@kSOr0o?jVo`V^Kp%7mlP_KI}OI36m&KI*a zN(GLGtIp(SXt)$p8bW5ik_8#@a4hW98bf?_)1sKO3fhUwPsg^vYho;)XdBnKKVmo} ze0>VUa+4khOpX_G&h&{}eXAgKIJqV!nuXBpH93W;GeOoZ=?_!78;<$J^B&aVFnG+Z zonz@%vehO%*1qDamX(jRI4l#%$T7Et;N)}bNaFf|nbL!iW##k1x{^{|+Sk(GJf~CR z5mRZ#!DK&;VZN-SDHyhPreP>Azg$x$X^SiQlGkIa!HYI16vv)KEPW%Z8+%dnyIzno zfk3T31%9<%a$V1_I66!Tj^|Z{Q+!mS$Y_sE__`l~m}i2L;&h?b(TpDt3!!my{L6Kb2|#76jmxbw?0fm^+@q78+(5-dcSoNm*q%f zN4!I)W*k7@hL$X0Z^wD4c(R6)N-;kBkWeH1C9s?l#KQZ3nTmCx4q2-kip7QA-EiiP zs(Gt}=Qbl7+@);6qX|uiD^9G@b#g-b=f8te?h!gGoV=9a*gaMFHOE}TDY4reuJAMm zl!OfhkF{4dDe7}_N6ZOcXBS$5YRf%hQ2fVtf99EbEnj+d0ZLU&uk38Q4Sx^iF+DPv z3e%r>s0*pcp=QkeIjW|t3d_5w5icRw(%HIh_~D@mL)NPlW~%99mSX)ky?1Xkil+^d z(_lC~sC7^^gso#Up*)rQ~`w0TEc|k5=}nm#;x@Ix>v7&))~gsL!9)KNHM)6Eqwm#_yC(p+-V~j z#3+mTc7aDO#WBs80`EtOImIt(pQ*(KiP;`LNjt(Z@i5FD1ZU0neHoCPff~vEMXtu` z=$L}$>@ngmLCeEXI4Y<{>P93#=%+rY>v{5WkB>(O7AQyB549ucp62o(l&G}2Qe~yk z$!anIm6L)k{x-Ba41c2pHRgQ?;fr+s@;Oey0GyGilM2T8>zlCgliFGjt00E0aVc6i z3+arqdr3A{wVqL{D^%abE`kgGo<|*CWa&Nc8q!ff`2v2Z41uc#ZD;3L;%>8SMXt?w zRx-izx zpxyVBo&EbenU46j?2xA?`bGdBZFIjNSf;7`6GouKoh19i{+p*>w`IrFW=63CmZnk9 z)80kp)|1}u5$TjkJ*=v}{tR&>Iv_}6RrAab;LYD2vr9C_-j?H)dnGP-J`Ao=(B)!AZgr_<*|d3Stk<&$Xn!UOw?Xzt%OgOp4noQ1Ct+ zJT#9KVad29@E)*k(5yH|a2(kMsgv@l1gwRoXXUj`vs zwRl^3{;Jo2sMswRp;x$S7^?zTpMdqF)|;Xnc`mnT=s@$DD ztnfn5XoH3|{qJ_v2H`Pu;P|Z}%cPpkbBHbs@R%qz*fy`6ogULJA5eNJxtGC=^}=`< zfl630k`vt;NM^`RzQ=zjyR&9hQ}A;ZZ2H<*!8*T$uN5U0{s+ZBkK@+w8WsEQ>QKOR z{`ECfM4Uddj3gUte`^;H|D#l`%Lp;aCZf||=wsPiEYP3{L(7*Ifz`J|PENpF_n18E zKY?gSOd7J`giEK>)$m(_xYG0DD)1;(X+5*EY!<-0MD>Ki8Z;AzY>DjK_PwE3R~eXr zrOZIkVYQ2!tGrLBz_Ctpnl!pEQmL9)qVfx|W~ul=&pdZh2-7i`GjiAa^klEtMxuF@ za`iDJ_>G(}%gcxlM#j(rTpM(GiRD?9x^wcv35^2ZAUR=ZFR~rw!99SkGX3oCuSel6 z{@HTCpg5~wY483NDsZ$@7wK06HReyH@x6gdJ0*6%De+(tpEYU}j`Ocd?1%}BOPaaW zSlxPz#mDn`pm9&?ULbj^DxtAhh{Q@Spq9#)SVyWQXH2P_5zidkH5AkXx$;kSD8apy#t=2bpcz#1f`HFRYFIS$%St#5 zQK1tfH1z$dItl|0lUQdjJ|Ff-NRt=geDs!l_MH#p?XhNoy=^3G;@{C;&O!B}QI|&{u4iInPd)z1dR=(tbzv|Mu1B7H?78`pH_lH*;S>(JK zteleDwi{`MyiiO;QI0#!A59nDIWT^CQ_@o!b{ss`?tF(gA3M%oA_NmK*$Myy(6@(# znch++r)7E>LlRj>sE_yQm|WgnDXG%%jy7}CaI5L9NL`35F~Y5H=QWEah56+US`r^; zD{0l>Nu9xvE|&wFXTE0&lA0zK0}dIhSD!Y2mHOzt?Pth0iGe64k$b+1-rt!d7Fxp&)E{g=otk=A!i)cea`BvO-ISCXGvL`L zG)6H`hb8*weZZTVAo*R7(_U%R)x7oma;8SEv6bf=|4XDmR2ZbiiiwNcUWbrehmh4s z%>FHhTbh+<1Nldl`3sn)+YBP1dJ)~uF%QX^-H%FBvbucB#BYIP4`V@EG@#ypkrgwK_T5=9dD#04^Ru zV~C=zh@;HOhjDUnZ}u(SOwqn@T8t=oGhcA~(bp0XM@|(^!DD6|2#F1D1}e zHb*|kIP9{V?Z2W3CH_6v_5A6I5ERN~z%BXriwbcz&Oy9iI}7_jgA1X=eN44YZ6vbV z)|kfM>Tx}yhcI-8AbbHWz?ok(@KOvg#S`c>4Cu__#w=IP@m4-K!|+owIg$rw={8LLYghkLaKj+ z__b=vfdk{(luKtqMdw58W+pNF+1LwMQ&kknQRqE_7vpc;KZVyOnOU2W>oGJXcE*@^ z_7MAbrRl{&S|T!6996A%@d=Ilie9XqwcdG&_kjyAKtkS5J15FubWTgmt!%b{ReU z)y6)(Uy)ZEXb3$gs?aGz^oKL@pB)jv7QswKAxHC6-8ZGNA5|h{@@hLF&6+FH&8+rX z#cDk@(s|IyU1=pn|*}4Fx{c zKlc90dm>&>G>avr+0wi;kl6UcjS!rq#M>YSuA2mjv$9xa>x)lo&u0$3$Y4C!D#RhT zCO-^4hzouQ(Kn<~f|=X^=?bsJ^?cWyff-b|G5cb(F5TRd;su`(@&y+=S}M5Sy+Ek( z%G2oKeG;$&?EZl&kq~9#Q?wbRG@u&X&hyr0ivMuspq!E-=$h?ElPhpQ?E~nD+cgZZ zgcNTBlC(WW1!mg6dRo)>U9tuX|G?3~v2U7H>v{G{ZC~5s6w3<2kGG~H>(?0 znX#t1wFl>nUDz1FRk1h;MiR7VLS3*}^14h^Vg$`$S^$tsh-WXGFD2Wi>L@w0bOQbn zA|~rD7M*i1gU%RE`69vtn^2{ikp-&o$0l+w_VsrB2P`z>;1Pe4r8!riu< z2+y9s{KM(ksiL#Ri4b%-&;l4G6gr9tGpYt0cXNLIt$S_B7}6RLm#Q$9b6jFqR<5f- z)^vaVw!}_1DaIsj<&plTJ$)xs(M)>wO26P}EC5(Jjz!U>} z9!y0QuZf>n%^*`UGQNNB$)EYu{qU=3By|=$vq#%N>-0sb{u?yycqDbtT!)-Dhjw)S zTC2vQ0AY|}%e=@+)a4s~f@Fpb8Kbjrs^rB+_peXhKAFyszip>CkVMYvHkB}I=)KOx zYh4Q9_WPw4Uf}!owsS|s$WwET-%3IgZCUU-o$xO90$I{s31Vz^@#jzP3(jVlTo6W8u`+f- zS;O^Zoils4&q6*0k?X5P+2jUPx8tNg4aEd~^bI^(Cm~xg04Kb|lsVYxiYB;wDr0FW zf$EdY4FZjec=X{_&!d1EPBc8V{yE)RFNPx+)E2yb{p`WMd4->BZ%9;yvJc;TToYdA zps$dY(qTrZY)G{|akQ(V=})mJ7wpDg73HF1RCCuumjgbs!kd!M17h7us~Y zwN%zgX!87WbN01N@j-^Y`Ey~~hkf^fr$+pJ!cWY+Oug8YUu-I%9(A&?D?HU&XuKzr znwpHTJ4q7kYundP|3BK&LOFS=iiv8aEsb`_WoGTl4?|^mC0Igf{SR1T;s7~7U2t$E2{X;a`1@+ zPvFrU9?+(;(s7LKc&1boB!txqefpxe=WAnU#UIujiWXAGY3b?~W0Nb+J(3L75vw;K z@b|DxbSsu=TlkZp71}QTtu|brTo`{k&|7zZOd()aLR6Mk1_hica9<~UjOEC6WvQxk zF7s((1_Lf@XQ)u96Peh6&nVU#$?m)y_(mOOgNj^mVMU-)%pt>7F0%7eyo^h4aE;gS2RL3KHy=oj5=qtH`ePz@xi=1ZUd`( znlMTN>NBLFUXm_`@AOi{dX1SKd%4~n<*=7oI1opqyyC?AX}i%acLs4$qokEa#-vbq zu1N+->gOcL^+c;P*-__3;R2{{##rPWI>ScTkAA)=PG#2E-ply%cr&0J*UL?Tbc)h#HB zI3wK0OAFkBad?7VLlq8X3ctGuh19GLJpD)bQ|KB5TAr5FwNXZ}NcZZNOEx>G@$$ z*nt*bWv#9)uh5|W8TVTack#G0ThkL_9Shrw4pUx-bsg{f_YX4J3!n(up)04jBtp(3 z`F@i)qRkiN4Jk6@3IjKA$8n#Ht}Yy9M$5PL2#%o)>mHD`D(O!Po^Pb1({@f_2Oeqx zFNWH`ldZiw~l+ z1&J{o`**x*yJ1%tO~nbf9Cm%-_|(=#jP*9qkus~R5?Z7#LzMEg;U&*$jWiE44%Mo_rU5W(uTBo0yL&UwC~&IfBevF^Rg~?xTi$wPJ(A4 zM<#2XB)VI#M>JOMPs$aT9K^-)8X!1#?6Z$>e5jYx_6RN2(HH zEl{jAF<}$dN`dxh`dHai?9fJ{*ItM;_w4;8Ir-Cpl8yB$lz^>L3M9w(Gd^pRri(>5 z{Rt4q-yh3rAa%I!y|fkI{2cPF%wWZVYi-NBURj7B$dHEix$J)|x8ECyh#)9j0?(>b zIVRmqAQE;1$!QSt!_N-b&0@)E@|2FR{>gc4bh5N+m(AD9)sqIG6&rl*Y=`-x4-?lJ zEXby^5a$s~N4_UaF~+wz{BR$FHLQ94mq5TRh~wP-Re@@=MWiPx_^8?<()dfxFX61f zpBu4g6f)unCE+;1_-piNww-J{u({VZrV*Fs9KS z8jf4@OiE)S=a}F!vCV5rNP}eUjiR8t@^ZneabY9;@UudKfx7Pc4~w4u~O0 zID6lLEdaf2bFOTZct4aCfWnOAGv%!ys3AYB>(8eLD*FFwOgh4Il z0M#KkX>vXLk~T3>z<`QmnDyBu>;rK;OU6%!Z4F}tU$B*F(X`-QC}0xHr=gQU7_|zPjq;urbWDKgFjZVa0v;5 zWRTDI#(kG>ZZ9~+4fn^vPi(xHXT6Q5Y`oefj(pO#%i+KC(TcO`ydno_RUkAYwa2ZV z(p{*<(*la;cTluF(Mc}{~=i37$R2eFz@pE zDXEHgD@=uWSo`hKV-VA@rjO3U=#F!*q@0GMZ*)^0`0k)lU5&f=~ z)1+J0K3mxAW{59AF9){}cpI*X2|Mcf}_K&_G+KGpHI zwrJjkQGhZ*%abtXS%1F|23i8a=st@_hXA@Gte14}k2ri1vWF1(>J5B&ff9v`LFeKQDX<#QDY zI8rD-|1A$RO83?L@RA`0=^x+<5T*P6uj%8u8B&S`CduLax69NsGYV`K2z{ik_mI*R z@UYzfUqV{OvvY1@y79;eo;ps9GKFBOgtw{4J+#%TfA|vs+@U&f zmd`AW{77YBw9W;x^Q?U!5KqZaBm8^F0TUIu{C@q%L<}I^mYAndE&H{E0T*^Bg{T3G z5A#|4TrfIncp(y<%dr);?uL!`$t<4EJ~l#-vsblTDZ~7{-~j~ceZf;}J%Ebi4`4?S z`fYhm#GcFp3T1*cdXp{hj^zA*4N0I$g#2y58w%S1o&R?SCG&>3a8y&-d*C*m)fX5Ipo&0g&` zA5{}C@$&aa$KRUCVvJ3CBLoN68cVzb`VS;OR8V9yhfmV$YknuDh~;3JwL-5RK|lN1 z?o01tANgC^nulYOO-*67N}|Lrqi=IGmuN@eg2FjaX)rHCt zm$bAm?3A(ACGyN|4x7bwS-S2Ok9Fh(aiAl??X!=$1$Z8J$DDbuP`G)vy6Ha0l$v1* ztx$VOY8Um^VAv`%BiA|6=XzCqe3X*%TrS1MlXw6wLf>n&!LD;yv=IBo;kMkDw1p6d$jyXvPxi7>V} zTfWoFv4(PJ?Li|6C8-h$H3CsUaG>n8mkdDa$z0G~Z`GvS>Ddf&ia8ece{YA7c)Bry zp8gULj47AN|5X?B%5@!f&DZDiNZud^NAoKDqwJCNu~fHuo=p)Drz3aV*q|rs&XhEb zbL1Ng!T6D;sW}zXO_D(Hjy;4<0v81|w*%|DdJCKvlzIVM4r+{{^gvd5Z*D~Sc5#HZ@_R=S4uBo)R2AsXs^6i8Z$gK#F{(He4qh<4T*;u^)M5FqY)F z8W{prZ+v#d7(=@bFi=4S8o)1@s>$htQBPj{Bn5t~d7fo5Snw(eNsUvywbg4TOSk!a zjB)bbyiUvPH3dS*)e0ib*h$1fO95u7#=jZ%hkVL?ynM5Mpxb;K4q!E9;@06c)_oC8 z`oM7p#f!lx!9qh?TqPo@o%>A8lZNp+AyCZnL^xgtesvMxW!ZBniKDc_#QGhoqR;2N z8!Edid(VhW*{{#p&G?rWN6!H>_dWPY05nJ6{1T&uJ7DbV;ErztRDBT^2psv5`A|v?Wfw8F!6sM~z5qXDvg6ly5ukEyICn zK@}6~+97=nekl~Q3+S*!WjpObeGx8a$Prr-q^!TQ{JgD0*okt;>I zbmQg8n-Pr+In;`ozjR+$_3oc}BvoLNdWsX3=dc!^>^64`*IW9+8y#b!k#|Ra|3w0= z@xg8ymuYqoT*vn@naiEA^POf1WiLNo_v>Ux?!y4|yZEB?skO2Ka0;?f4l*_Jg=K04 zVSRKu=b{dtZ_g;;L+Bx~fy$7m>@AzO<4+QBAF1XPY@0_XF)g#n7!ptH?6C=7l0VYU zN%Hc)#r^zL$Cn(6&c;FH_f#gKs_r=qK*yKsn@RpO=>hTRLzTei+X3hAA=Y&tPUBa2 zE6KsJ=cRA-t$&YpMh8}$3u}VGmh0vd#?ml=LLsV){m&X&+tR+k>W&VcW6DMg)Y59`Rk4WygPA_`OPA`N2 zu721r3?~O(BreM@7h(veNt0SHI$$8hOEG^AFuuu@uIECcXSPv^|0ah(sgx}A4N!sN z?hKPm8f!`6*;t!sens==;hQSp#{kJ72~bRHy1ViRAY8*^wNrs(qx3EdNd-WuXL@b| ziN93Rf-?sI?3>Vs;^X7lgP_YMQ9x+{7@iZ8OBWWs=JN%W_5QU6t(r1I!b)K=l}L_< zm!hD*9yQO9rPlo&k~+KV%NEMZfHNip3GYyX@~X#S^xBy6aiCG@K|fXzWmZDk8?Wgo z(&@!Zkos6BCcG*k&CYqBUz!~nn6`BtmBt2&%!+D-6B7&IWGvkZzJNHM9B0^fP-(_&sT|6e1?B-x zbfeM$TRV?buS&Ot0S{FDPx32&rZPvxSKo=QsQX?M9HvMB@={i^zfOcMa*a9}q@B>} zhweIZLf3`dd*91-#}PQ)?S27pcygf1g3;tAzvJw93SK7hb^AkD-4Z01?niC}2_O6B zN&c3lF;qAuf`8|#N|mQZZij8QWKCnXSI?XYs@*?#HEYGxW|s_@)PqV>W~@U+kD)Zk zAYtg6)%fdb$JMGA$`C{DqR zl?BubY!Z#jJyOir+hlPkEPJCdxwwhYc9aX5c^)V5@Jy11bdNt^yQ9P`mh6aIea;W z1h;ISu0fw%fmu zBidKDTZRZh-c$@B*jPQT=It*QUGw;kWv16O2+r?fq6a?B-&8A{Aq$r@sJq!LwP6z1 zGyL&O^K7ii@VxXY!Plz{!Z;>&?7ark@X{0!lG6+8({#SLrCA?Smbk1 zYHeM)P}B9s1t@g10>YGiTXdZfgJa4FJ)x+t%g~BUPYp6!N@-50^6;5RQ=QbH_aCc} zl+8>zz=oHhn-ad^ZTdjAv3u()L2s}y99zYCm3Y7{NZxoHS#Ry!;J; zD)$3}giW#K6N*;y0Z{d^tCNGnE^ca9x1aVVm^|dVv{X~Puq^lw+aX5r_x&zoZ%T}k z%8)U@#5nn47W;%~nFHFNkrRg!9$c6aT9Xi0;*407T9z6Dm?ASxmr5dccl;%GKdN2& zk{ai>lbnj6mZ7-q8dLxu1K@LU)$Z3VmjDGjfUqL+P(h&kk{w8FL^!*vck51vuwVnV z7?i5qIqYJ0%V6ZqPpHa^{#zMX0bBO+cG4c(%zW&A=XaC|KcEsCxeR{xBL!A_? z_`%FK3uR}nt-$O}MmbQE?!S3>x~^TR=?D;Nk{X+B?+TuFVSl*zoH3^JRg=ErZN`@#k$Ku_vyU@|;3WbV}^# zBObr~7gCEpntlt^Tg_}Eu_rHolr);X`_=x6rJ0*a<2dwM=9N^4r1gn03yCoVwR~#0 zYUyibF~K_AqMmU1!(r+1JsnCkPYn!^Y_H60>@(#DU|w7zDnRVxnhl*WOS+L}o%E>} z%hz)w zf5EnhV@B#^(<2UEx!&!}61#Z-Y}fN164hLQXkPWO^G}U2@yvB;?1cCS(>w9yU&(-$ zgEOji#$xjnJ(iqe3R1MmrEwS=7d7dCidRdEte@af1LM`_K#>gFcT<( z4s*=w>ga_y&-$?9#YM^L!LBiba{>lD4sOA0!6UP2$cD-#a2&!(rGwFqog*ZZ_`O(B zp`)r)A+d+$-R1LI&}dv-GYf<3tf=O!41Wf7ggRTCkVM>I%`TQv(07F0U_j`s6YF!> zpWM35v@gt)3lJzS7#*Ph?ek@hT`a33Pcidonck#nA$=xS(6wk7l@+>f2 z0w}zx0lF+IHXx^E7ji^d5^b7TB5X}m-WEepaRTcJLpx1| zET6|Ejmusxf23=^Qul1_Bv1VwCu|YcMmZ$^vE)!9BprOG}{DTD&%@N;&hxJZB%fNIBKk=V#=x zDYeI*AJ@wyT3E5|%D(Ch%J}h{D(x6e8emd9?X>CA5UAoyBwfLJM1<{eJndiGkGAjI ztMP-Yg&LUCsZ4|y`-@KG8ZGu8-W^6|NSe!S!T|M4iClL`< zP#jO;GOJM~Y*m=(_L=k&iCi6dZ;2d3g}8!%){3DOjtCBQW!Xh03YJ}eiBrhst48Tl zvjaB=5?*4wod6fXnFU{PNxO9Pb(@nv6`+Nsy&^^LB|4tD^pPs&-(SSs8=SO$6xDG_ z42t~<{zRWQIJcm8oQA%)9D8%y5^eR7Q}i-z^kb3B`VbTW5Osz%Y6h>^Wx`c(?Y|lq z#Layr5JbokX?7p%j~T#d+8wMe#8(MhC1LcS#r!VzRN+aLrkV`2UgmiuD~gJXd_EGt z$P%x#PxshZfDx0`b+RDmbF9D=iCi^l-|)xXh+hU)r@ke#S;ym^4|hxjaZx5|GC%#! znO>>~@MXEY(=cAGz!X`yB8Z`rB?HG|CO#!K&D!&{-5_#T>K1Gux@j5P94VgB(lX(r zrTo|;c_o9%E23Yc#*BIO=oNw%$1gB%RHM6_=-~4`ccmXtrpaqDO(*bibJniS( zb_&&$j$6Qa&II1bRkG@(EZX=Oea}!HFJPY&Pt65ermq3mq=?`sN5u{7vB{o zTc4nJr*xwZ$+1y|VJJCTFhMa%K#G$i<7DEQ=*7rUEU0Qc4tEJLVp5Wola^v+{7&8dC=5Nh*Q`Z!i-#I|$1Y8u70NhXk9;m}b zkWYeJwEsdm*>W)4B67V(||yp(Y~P8 z2>>GSbegqT?{GLQx`>`#H9M9m z8i1*k;CG`ImjRc8l>$G^lNSXjMx&|-FIUII$)hi~5%a0K&uZm3cLEZ5M1cr(Y#x?e z^vl7SjNzm8Ria8L}O8oos3d!8)AkEP^ zBxDS+4}2Ns75+{C?Cr=w2?BQ{rm)c|m)+K4AE3R-HTq#OyJ`KOoPC#&VHgGwM2k}@ zwkYI>M&q}h0PIN6FSCh<-8KbIiNFjC2G!4)CO&eL^cm9@vMc`G9 zVrAACV-Jp!YCxjs{#E8FMb=Qj1e!ymvA?A4;y!jVp+Z*VkF>+Ygu!}apteI!MTt#9 z(ie-N0E2niL=uze0ew>c-#*q;!h(2BpRDS($Et*w+Yh{;?ikCm|C9IX7UB6`Xg47} zx$TsRSz}2{t)kr^Y@OqXLHHseeg1ys$x&#_MnXCdH@AY35tXlnj;@0BpF*pMtx?If z_4}K?2IAd)?ccj_X<$(@y$?B26Q?IM585fmj#VgG-^*SXH3f5Y zNYmfeIt{XG=76-Oh*Ool&gTF;vVq9t;DO_QK=z(w`R>5~xC@P_YsYu(eBOf!OaMB1 zU3iPG!Wb%I8MMO|ax=f&e|NE*J~{bpA@~qTGEjo=?xe4grI)Kj;Ryo_!T6s)ug#cw z^p&Z-h7GWE16zpwc44@Tsi|r0%U|3-3xYQ^pv;YpjYY-9C?0LcnAv<5N%{Hoh|G+F zg4FBlAYRi(>~!gK>L!onw5Lk}X#R_SR15A;aq-ea0tF3d>FDYLPhYfMt|hk|eBYh; z^(&UArbWS*7w9KbP-Ipk?N4V+_g@Oc;_ti_N=i=F)7NK(&Y)Ci7bJc2*(a!#rpN3r}M(80HD2C1_NMNe^h@qg3Ll?I?)7XFK|v3OLr zt39#C-+j6GI&Zxx`mEpW)p$52D248+wo^6*k0A&|$!Fd&KCnGjknC6(O3B8? z_UP%rdiV6F=XG`iga|!V0n46}mVHYvzfsZIy;JwIM2`1eQ5blh+Xeo(n$2J8>xHIg zW=g()r^Tb>1%awezp6V`sIo>qT@E30d>_b8uGGEOmn;}`=42lF@0ZidmkKT}l@lSs zm7hN==;#nt=oF?s*O8`3Dp`rdVju=51E;y}!q(4s(b?JAXnYP;#&yhY*#We1D4>?> zzm<^|WL=SRAR^gYPgC=#tAAg@3F3PrC49D4&Zcy*K(345DVi6&laWqcmf_*8M&?S) zBzwQ>9@mS}p9K}gHC0hj(F!CnEGu6UkqA#@AubU?EG_XtQ4vL+qy_&Mt;Qn{Sh%4M V!|npbcR&XG literal 13095 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 aXLe}lb5S?A3I6j1Lsd~jp-j#q;Qs(XC!Q<- diff --git a/examples/visual-docs/output/chart-pie-light.png b/examples/visual-docs/output/chart-pie-light.png index d4757f8af328d4bc41efafbc966d54479dab3bb6..28881c3c56b633cdc44947a75f2dfc02fbc60b11 100644 GIT binary patch literal 13650 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 XXk!6;KM~+ZQXmb4HoQXFGW34{#X(IJ literal 11494 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 Date: Sun, 14 Jun 2026 07:13:00 +0400 Subject: [PATCH 112/119] docs: add Xml/Yaml decoupling refactor plan --- .../2026-06-14-decouple-xml-from-yaml.md | 1668 +++++++++++++++++ 1 file changed, 1668 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-decouple-xml-from-yaml.md diff --git a/docs/superpowers/plans/2026-06-14-decouple-xml-from-yaml.md b/docs/superpowers/plans/2026-06-14-decouple-xml-from-yaml.md new file mode 100644 index 0000000..e6fe8de --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-decouple-xml-from-yaml.md @@ -0,0 +1,1668 @@ +# Decouple FlexRender.Xml from FlexRender.Yaml — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `FlexRender.Xml` depend only on `FlexRender.Core` (drop `FlexRender.Yaml` + `YamlDotNet`) by introducing a format-neutral node model and moving the shared template-parsing engine into `FlexRender.Core`. + +**Architecture:** A new pure node model (`TemplateMapping`/`TemplateSequence`/`TemplateScalar`) lives in `FlexRender.Core`. The parsing engine (`ElementParsers`, `ChartParsers`, `ShapeParsers`, `KnownProperties`, `NodePropertyHelpers`, and the document-root parse logic) moves into Core, rewritten against the neutral nodes. `FlexRender.Yaml` keeps the public `TemplateParser` facade: it loads YAML via YamlDotNet, converts the YamlDotNet DOM → neutral nodes (`YamlNodeConverter`), then calls the Core engine. `FlexRender.Xml` converts `XDocument` → neutral nodes (`XmlNodeConverter`) and calls the SAME Core engine. YamlDotNet stays only in `FlexRender.Yaml`. + +**Tech Stack:** .NET 10, C# latest, xUnit, YamlDotNet (Yaml only), `System.Xml.Linq` (Xml only). AOT-safe, no reflection, `sealed`, file-scoped namespaces, `TreatWarningsAsErrors=true`, `AnalysisLevel=latest-Recommended`. + +--- + +## Key facts discovered during exploration (read before starting) + +- All engine files in `src/FlexRender.Yaml/Parsing/` currently use namespace **`FlexRender.Parsing`** (NOT a Yaml-specific namespace). `TemplateParseException` and `PathDataParser` **already live in Core** under `FlexRender.Parsing`. Therefore moving the engine files to Core keeps the **same `FlexRender.Parsing` namespace** — no consumer `using` changes, no namespace breakage. +- `ChartParsers` and `ShapeParsers` are `public static` classes. `ChartElement.MaxDataPointsPerSeries` is `internal` in Core and already accessed by `ChartParsers`; Core already has `InternalsVisibleTo("FlexRender.Yaml")`. After the move this access is intra-assembly (Core) — fine. +- The `Charts` types (`ChartType`, `ChartSeries`, `ChartPoint`, `ChartPalette`, `ChartPalettes`, `ChartTheme`, `ChartThemes`, `LegendPosition`, `PieLabelMode`) are in `FlexRender.Charts`, already in Core. +- `ExprValue` and all AST types are in `FlexRender.Parsing.Ast` in Core. +- **Three test files** touch internals that change type: + - `tests/FlexRender.Tests/Parsing/GradientObjectParseTests.cs` — builds `YamlMappingNode`, calls `ShapeParsers.ConvertGradientObjectToCss(YamlMappingNode)`. + - `tests/FlexRender.Tests/Parsing/Xml/InternalEntryPointTests.cs` — builds `YamlMappingNode`, calls `TemplateParser.ParseDocumentRootForTests(YamlMappingNode)`. + - All other parsing tests (incl. `ChartKnownPropertiesTests`, `XmlValidationTests`) call only public `Parse(string)` and stay unchanged. +- `src/FlexRender.Yaml/AssemblyInfo.cs` has `InternalsVisibleTo("FlexRender.Xml")` and `InternalsVisibleTo("FlexRender.Tests")`. After the move, `FlexRender.Xml` no longer needs Yaml internals; `FlexRender.Tests` still needs Yaml internals only for the YAML `TemplateParser` facade's `ParseDocumentRootForTests` — which we relocate to Core. Core needs `InternalsVisibleTo("FlexRender.Tests")` (already present). +- Per-file YAML-DOM token counts (size of mechanical swap): TemplateParser 31, ElementParsers 41, ChartParsers 40, ShapeParsers 27, KnownProperties 3, YamlPropertyHelpers 41. +- Build command: `dotnet build FlexRender.slnx`. Test command: `dotnet test FlexRender.slnx --framework net10.0 --filter "..."`. NEVER pipe dotnet through tail/head/grep. Use `--no-gpg-sign` on commits. Conventional commits, NO attribution lines. + +--- + +## Neutral Node Model (locked type definitions) + +Namespace: `FlexRender.Parsing.Nodes`. Location: `src/FlexRender.Core/Parsing/Nodes/`. Zero external dependencies. All `sealed`. + +```csharp +// TemplateNode.cs +namespace FlexRender.Parsing.Nodes; + +/// +/// Base type for the format-neutral template node model produced by format parsers +/// (YAML, XML) and consumed by the shared parsing engine. Pure, AOT-safe, no external deps. +/// +public abstract class TemplateNode +{ +} +``` + +```csharp +// TemplateScalar.cs +namespace FlexRender.Parsing.Nodes; + +/// A leaf node holding a single (possibly null) string value. +public sealed class TemplateScalar : TemplateNode +{ + /// Initializes a new scalar with the given value. + /// The scalar string value (may be null). + public TemplateScalar(string? value) => Value = value; + + /// Gets the scalar string value (may be null). + public string? Value { get; } +} +``` + +```csharp +// TemplateSequence.cs +using System.Collections.Generic; + +namespace FlexRender.Parsing.Nodes; + +/// An ordered list of child nodes (the neutral analogue of a YAML sequence). +public sealed class TemplateSequence : TemplateNode +{ + private readonly List _items; + + /// Initializes an empty sequence. + public TemplateSequence() => _items = []; + + /// Initializes a sequence with a starting capacity. + /// The initial capacity. + public TemplateSequence(int capacity) => _items = new List(capacity); + + /// Gets the ordered child items. + public IReadOnlyList Items => _items; + + /// Appends a child node to the sequence. + /// The node to append. + /// Thrown when is null. + public void Add(TemplateNode node) + { + System.ArgumentNullException.ThrowIfNull(node); + _items.Add(node); + } +} +``` + +```csharp +// TemplateMapping.cs +using System.Collections.Generic; + +namespace FlexRender.Parsing.Nodes; + +/// +/// An ordered, string-keyed mapping of child nodes (the neutral analogue of a YAML mapping). +/// Preserves insertion order and supports key lookup and key enumeration for validation. +/// +public sealed class TemplateMapping : TemplateNode +{ + // Insertion-ordered keys + value lookup. Keys are compared ordinally (YAML/XML keys are case-sensitive). + private readonly List _keys = []; + private readonly Dictionary _values = new(System.StringComparer.Ordinal); + + /// Gets the keys in insertion order. + public IReadOnlyList Keys => _keys; + + /// + /// Adds or replaces a child node by key. If the key already exists its value is overwritten + /// but its position in the key order is preserved (last-wins on value, matching YAML semantics). + /// + /// The child key. + /// The child node. + /// Thrown when or is null. + public void Add(string key, TemplateNode node) + { + System.ArgumentNullException.ThrowIfNull(key); + System.ArgumentNullException.ThrowIfNull(node); + if (!_values.ContainsKey(key)) + _keys.Add(key); + _values[key] = node; + } + + /// Tries to get any child node by key. + /// The key to look up. + /// The child node when found. + /// True when the key exists; otherwise false. + public bool TryGet(string key, out TemplateNode node) => _values.TryGetValue(key, out node!); + + /// Tries to get a child mapping by key. + /// The key to look up. + /// The child mapping when the key exists and is a mapping. + /// True when the key exists and is a ; otherwise false. + public bool TryGetMapping(string key, out TemplateMapping mapping) + { + if (_values.TryGetValue(key, out var n) && n is TemplateMapping m) + { + mapping = m; + return true; + } + mapping = null!; + return false; + } + + /// Tries to get a child sequence by key. + /// The key to look up. + /// The child sequence when the key exists and is a sequence. + /// True when the key exists and is a ; otherwise false. + public bool TryGetSequence(string key, out TemplateSequence sequence) + { + if (_values.TryGetValue(key, out var n) && n is TemplateSequence s) + { + sequence = s; + return true; + } + sequence = null!; + return false; + } + + /// Gets the scalar string value for a key, or null when absent or not a scalar. + /// The key to look up. + /// The scalar value, or null. + public string? GetScalar(string key) + => _values.TryGetValue(key, out var n) && n is TemplateScalar s ? s.Value : null; +} +``` + +These four files are the entire neutral model. Everything the engine needs maps onto them (see Type Substitution Table). + +--- + +## Type Substitution Table (apply mechanically when moving engine files) + +| YamlDotNet construct | Neutral equivalent | +|---|---| +| `YamlMappingNode` (type) | `TemplateMapping` | +| `YamlSequenceNode` (type) | `TemplateSequence` | +| `YamlScalarNode` (type) | `TemplateScalar` | +| `YamlNode` (base, e.g. `ParseTupleScalar(YamlNode ...)`) | `TemplateNode` | +| `using YamlDotNet.RepresentationModel;` | `using FlexRender.Parsing.Nodes;` | +| `using YamlDotNet.Core;` (TemplateParser only) | (removed from Core engine; stays only in Yaml facade) | +| `parent.Children.TryGetValue(new YamlScalarNode(key), out var node) && node is YamlMappingNode m` | `parent.TryGetMapping(key, out var m)` | +| `parent.Children.TryGetValue(new YamlScalarNode(key), out var node) && node is YamlSequenceNode s` | `parent.TryGetSequence(key, out var s)` | +| `node.Children.TryGetValue(new YamlScalarNode(key), out var v) && v is YamlScalarNode sc ? sc.Value : null` | `node.GetScalar(key)` | +| `node.Children.TryGetValue(new YamlScalarNode(key), out var v)` (raw node, e.g. `fonts`, `elseIf`) | `node.TryGet(key, out var v)` | +| `seq.Children` (iterate) | `seq.Items` | +| `seq.Children.Count` | `seq.Items.Count` | +| `seq.Children[0]` / `tuple.Children[i]` | `seq.Items[0]` / `tuple.Items[i]` | +| `mapping.Children` (iterate `(keyNode, valueNode)`) | iterate `mapping.Keys`, then `mapping.TryGet(key, out var valueNode)` (see ConvertMappingToDictionary rewrite) | +| `node.Children.Keys` (validation key iteration) | `node.Keys` (already `string`, no `YamlScalarNode` cast needed) | +| `item is YamlScalarNode scalar ? scalar.Value` | `item is TemplateScalar scalar ? scalar.Value` | +| `item is YamlMappingNode m` | `item is TemplateMapping m` | +| `item is YamlSequenceNode s` | `item is TemplateSequence s` | +| `(x as YamlScalarNode)?.Value` (error messages) | `(x as TemplateScalar)?.Value` | +| `entry.Key is not YamlScalarNode k \|\| ...` (fonts mapping) | iterate `node.Keys` and `node.TryGet(key, out var value)` — key is already a `string` | + +Note: `TemplateMapping.GetScalar(key)` replaces the helper `GetStringValue(node, key)` *primitive read*, but the engine keeps the higher-level `GetStringValue(...)` helpers in `NodePropertyHelpers` (they call `GetScalar` internally). Keep the public helper method names identical so call sites stay unchanged. + +--- + +## File Structure (what gets created / moved / modified) + +**Created in Core:** +- `src/FlexRender.Core/Parsing/Nodes/TemplateNode.cs` +- `src/FlexRender.Core/Parsing/Nodes/TemplateScalar.cs` +- `src/FlexRender.Core/Parsing/Nodes/TemplateSequence.cs` +- `src/FlexRender.Core/Parsing/Nodes/TemplateMapping.cs` +- `src/FlexRender.Core/Parsing/Engine/NodePropertyHelpers.cs` (moved+renamed from `YamlPropertyHelpers.cs`) +- `src/FlexRender.Core/Parsing/Engine/KnownProperties.cs` (moved) +- `src/FlexRender.Core/Parsing/Engine/ElementParsers.cs` (moved) +- `src/FlexRender.Core/Parsing/Engine/ShapeParsers.cs` (moved) +- `src/FlexRender.Core/Parsing/Engine/ChartParsers.cs` (moved) +- `src/FlexRender.Core/Parsing/Engine/TemplateEngine.cs` (new: holds the document-root parse + element dispatch extracted from `TemplateParser`) + +**Created in Yaml:** +- `src/FlexRender.Yaml/Parsing/YamlNodeConverter.cs` (YamlDotNet DOM → neutral) + +**Created in Xml:** +- `src/FlexRender.Xml/XmlNodeConverter.cs` (XDocument → neutral; rewrite of `XmlToYamlNodeConverter.cs`) + +**Modified:** +- `src/FlexRender.Yaml/Parsing/TemplateParser.cs` (becomes a thin facade over `YamlNodeConverter` + `TemplateEngine`) +- `src/FlexRender.Xml/XmlTemplateParser.cs` (uses `XmlNodeConverter` + `TemplateEngine`) +- `src/FlexRender.Xml/XmlFlexRenderExtensions.cs` (drop `using FlexRender.Parsing;` if unused — verify) +- `src/FlexRender.Xml/FlexRender.Xml.csproj` (reference Core, not Yaml) +- `src/FlexRender.Yaml/AssemblyInfo.cs` (drop `InternalsVisibleTo("FlexRender.Xml")`) +- `tests/FlexRender.Tests/Parsing/GradientObjectParseTests.cs` (build neutral nodes) +- `tests/FlexRender.Tests/Parsing/Xml/InternalEntryPointTests.cs` (build neutral nodes, call `TemplateEngine`) +- `AGENTS.md` + `llms.txt` (doc updates) + +**Deleted:** +- `src/FlexRender.Yaml/Parsing/YamlPropertyHelpers.cs`, `KnownProperties.cs`, `ElementParsers.cs`, `ShapeParsers.cs`, `ChartParsers.cs` (moved to Core) +- `src/FlexRender.Xml/XmlToYamlNodeConverter.cs` (replaced by `XmlNodeConverter.cs`) + +--- + +## Phase 1 — Neutral node model in Core (additive, isolated) + +### Task 1: Create the neutral node model + +**Files:** +- Create: `src/FlexRender.Core/Parsing/Nodes/TemplateNode.cs` +- Create: `src/FlexRender.Core/Parsing/Nodes/TemplateScalar.cs` +- Create: `src/FlexRender.Core/Parsing/Nodes/TemplateSequence.cs` +- Create: `src/FlexRender.Core/Parsing/Nodes/TemplateMapping.cs` +- Test: `tests/FlexRender.Tests/Parsing/Nodes/TemplateMappingTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/Nodes/TemplateMappingTests.cs`: + +```csharp +using FlexRender.Parsing.Nodes; +using Xunit; + +namespace FlexRender.Tests.Parsing.Nodes; + +/// Tests for the format-neutral node model. +public sealed class TemplateMappingTests +{ + [Fact] + public void GetScalar_ReturnsValue_WhenKeyIsScalar() + { + var m = new TemplateMapping(); + m.Add("color", new TemplateScalar("#fff")); + + Assert.Equal("#fff", m.GetScalar("color")); + } + + [Fact] + public void GetScalar_ReturnsNull_WhenKeyMissingOrNotScalar() + { + var m = new TemplateMapping(); + m.Add("child", new TemplateSequence()); + + Assert.Null(m.GetScalar("missing")); + Assert.Null(m.GetScalar("child")); + } + + [Fact] + public void TryGetMapping_And_TryGetSequence_DiscriminateByType() + { + var m = new TemplateMapping(); + m.Add("map", new TemplateMapping()); + m.Add("seq", new TemplateSequence()); + m.Add("scalar", new TemplateScalar("x")); + + Assert.True(m.TryGetMapping("map", out _)); + Assert.False(m.TryGetMapping("seq", out _)); + Assert.True(m.TryGetSequence("seq", out _)); + Assert.False(m.TryGetSequence("scalar", out _)); + } + + [Fact] + public void Keys_PreserveInsertionOrder_AndAddOverwritesValueKeepingPosition() + { + var m = new TemplateMapping(); + m.Add("a", new TemplateScalar("1")); + m.Add("b", new TemplateScalar("2")); + m.Add("a", new TemplateScalar("3")); + + Assert.Equal(new[] { "a", "b" }, m.Keys); + Assert.Equal("3", m.GetScalar("a")); + } + + [Fact] + public void Sequence_PreservesOrder() + { + var s = new TemplateSequence(); + s.Add(new TemplateScalar("1")); + s.Add(new TemplateScalar("2")); + + Assert.Equal(2, s.Items.Count); + Assert.Equal("1", ((TemplateScalar)s.Items[0]).Value); + Assert.Equal("2", ((TemplateScalar)s.Items[1]).Value); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~TemplateMappingTests"` +Expected: FAIL — compile error, `TemplateMapping`/`TemplateScalar`/`TemplateSequence` do not exist. + +- [ ] **Step 3: Create the four node files** + +Create the four files exactly as defined in the "Neutral Node Model (locked type definitions)" section above (`TemplateNode.cs`, `TemplateScalar.cs`, `TemplateSequence.cs`, `TemplateMapping.cs`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~TemplateMappingTests"` +Expected: PASS (5 tests). + +- [ ] **Step 5: Verify full build still green** + +Run: `dotnet build FlexRender.slnx` +Expected: Build succeeded, 0 warnings, 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Core/Parsing/Nodes tests/FlexRender.Tests/Parsing/Nodes +git commit --no-gpg-sign -m "feat(parser): add format-neutral template node model to Core" +``` + +--- + +## Phase 2 — Move the property helpers into Core (foundation for the engine) + +`NodePropertyHelpers` is the leaf dependency every other engine file uses via `using static`. Move it first so subsequent engine files can compile against it. To keep the build green during the move, we COPY it into Core (neutral) while the Yaml originals still exist; we delete the Yaml originals only when the whole engine has moved (Phase 4). To avoid duplicate-symbol clashes (same namespace `FlexRender.Parsing`, same type name), this phase moves the helper as a single atomic file replacement is NOT possible while Yaml callers still reference the YAML-typed version. + +**Sequencing decision (important):** The engine files form one tightly-coupled unit (they share `using static ...YamlPropertyHelpers` and call each other). A partial move causes duplicate type names in the same namespace. Therefore Phase 2–3 move the WHOLE engine in one coherent commit (Task 3), guarded by first creating the converter (Phase test data) — there is no clean smaller intermediate that compiles. The plan below makes Task 3 a single large mechanical move; Tasks 2 and 4 wrap it so each *commit* leaves the build green. + +### Task 2: Add YamlNodeConverter in Yaml (additive, build stays green) + +**Files:** +- Create: `src/FlexRender.Yaml/Parsing/YamlNodeConverter.cs` +- Test: `tests/FlexRender.Tests/Parsing/YamlNodeConverterTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/FlexRender.Tests/Parsing/YamlNodeConverterTests.cs`: + +```csharp +using System.IO; +using FlexRender.Parsing; +using FlexRender.Parsing.Nodes; +using YamlDotNet.RepresentationModel; +using Xunit; + +namespace FlexRender.Tests.Parsing; + +/// Tests that the YamlDotNet DOM is faithfully converted to the neutral node model. +public sealed class YamlNodeConverterTests +{ + private static YamlMappingNode Load(string yaml) + { + var stream = new YamlStream(); + using var reader = new StringReader(yaml); + stream.Load(reader); + return (YamlMappingNode)stream.Documents[0].RootNode; + } + + [Fact] + public void Convert_MappingScalarSequence_ProducesNeutralTree() + { + var yaml = Load(""" + canvas: + width: 300 + layout: + - type: text + content: Hi + """); + + var root = YamlNodeConverter.Convert(yaml); + + Assert.True(root.TryGetMapping("canvas", out var canvas)); + Assert.Equal("300", canvas.GetScalar("width")); + + Assert.True(root.TryGetSequence("layout", out var layout)); + var item = Assert.IsType(Assert.Single(layout.Items)); + Assert.Equal("text", item.GetScalar("type")); + Assert.Equal("Hi", item.GetScalar("content")); + } + + [Fact] + public void Convert_NestedSequenceOfSequences_Preserved() + { + var yaml = Load(""" + series: + - data: + - [1, 2] + - [3, 4] + """); + + var root = YamlNodeConverter.Convert(yaml); + Assert.True(root.TryGetSequence("series", out var series)); + var s0 = Assert.IsType(series.Items[0]); + Assert.True(s0.TryGetSequence("data", out var data)); + var tuple0 = Assert.IsType(data.Items[0]); + Assert.Equal("1", ((TemplateScalar)tuple0.Items[0]).Value); + Assert.Equal("2", ((TemplateScalar)tuple0.Items[1]).Value); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~YamlNodeConverterTests"` +Expected: FAIL — `YamlNodeConverter` does not exist. + +- [ ] **Step 3: Implement YamlNodeConverter** + +Create `src/FlexRender.Yaml/Parsing/YamlNodeConverter.cs`: + +```csharp +using FlexRender.Parsing.Nodes; +using YamlDotNet.RepresentationModel; + +namespace FlexRender.Parsing; + +/// +/// Converts a YamlDotNet representation-model tree into the format-neutral +/// model consumed by the shared Core parsing engine. +/// This is the only place YamlDotNet types cross into the neutral world. +/// +internal static class YamlNodeConverter +{ + /// Converts a YamlDotNet document root mapping to a neutral . + /// The YamlDotNet mapping node (document root). + /// The equivalent neutral mapping. + /// Thrown when is null. + internal static TemplateMapping Convert(YamlMappingNode root) + { + System.ArgumentNullException.ThrowIfNull(root); + return ConvertMapping(root); + } + + private static TemplateNode ConvertNode(YamlNode node) => node switch + { + YamlMappingNode m => ConvertMapping(m), + YamlSequenceNode s => ConvertSequence(s), + YamlScalarNode sc => new TemplateScalar(sc.Value), + // Aliases/anchors collapse to their resolved node in the representation model; + // any unexpected node kind becomes an empty scalar to preserve total conversion. + _ => new TemplateScalar(null) + }; + + private static TemplateMapping ConvertMapping(YamlMappingNode mapping) + { + var result = new TemplateMapping(); + foreach (var (keyNode, valueNode) in mapping.Children) + { + if (keyNode is YamlScalarNode { Value: { } key }) + { + result.Add(key, ConvertNode(valueNode)); + } + } + return result; + } + + private static TemplateSequence ConvertSequence(YamlSequenceNode sequence) + { + var result = new TemplateSequence(sequence.Children.Count); + foreach (var child in sequence.Children) + { + result.Add(ConvertNode(child)); + } + return result; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test FlexRender.slnx --framework net10.0 --filter "FullyQualifiedName~YamlNodeConverterTests"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Verify full build still green** + +Run: `dotnet build FlexRender.slnx` +Expected: Build succeeded, 0 warnings, 0 errors. (Converter is unused so far — `CA1812`/unused is suppressed because it is `internal static` and referenced by tests; if a warning appears, the next task wires it in.) + +- [ ] **Step 6: Commit** + +```bash +git add src/FlexRender.Yaml/Parsing/YamlNodeConverter.cs tests/FlexRender.Tests/Parsing/YamlNodeConverterTests.cs +git commit --no-gpg-sign -m "feat(parser): add YamlDotNet-to-neutral node converter" +``` + +--- + +## Phase 3 — Move the engine into Core (THE BIG STEP — HIGHEST RISK) + +This is one atomic commit because the engine files share a namespace and call each other; a partial move produces duplicate type names. The safety net is the full test suite run at the end. + +### Task 3: Move and retype the parsing engine into Core + +**Files:** +- Create: `src/FlexRender.Core/Parsing/Engine/NodePropertyHelpers.cs` (from `YamlPropertyHelpers.cs`) +- Create: `src/FlexRender.Core/Parsing/Engine/KnownProperties.cs` +- Create: `src/FlexRender.Core/Parsing/Engine/ElementParsers.cs` +- Create: `src/FlexRender.Core/Parsing/Engine/ShapeParsers.cs` +- Create: `src/FlexRender.Core/Parsing/Engine/ChartParsers.cs` +- Create: `src/FlexRender.Core/Parsing/Engine/TemplateEngine.cs` +- Delete: `src/FlexRender.Yaml/Parsing/YamlPropertyHelpers.cs`, `KnownProperties.cs`, `ElementParsers.cs`, `ShapeParsers.cs`, `ChartParsers.cs` +- Modify: `src/FlexRender.Yaml/Parsing/TemplateParser.cs` (becomes facade) + +- [ ] **Step 1: Move + rename `YamlPropertyHelpers.cs` → Core `NodePropertyHelpers.cs`** + +Move the file to `src/FlexRender.Core/Parsing/Engine/NodePropertyHelpers.cs`. Apply: +- Rename the class `YamlPropertyHelpers` → `NodePropertyHelpers`. +- Replace header `using YamlDotNet.RepresentationModel;` with `using FlexRender.Parsing.Nodes;`. +- Keep namespace `FlexRender.Parsing`. +- Apply the Type Substitution Table to every method signature and body. Specifically: + - `TryGetMapping(YamlMappingNode parent, string key, out YamlMappingNode result)` body becomes a thin delegate to `parent.TryGetMapping(key, out result)`: + ```csharp + internal static bool TryGetMapping(TemplateMapping parent, string key, out TemplateMapping result) + => parent.TryGetMapping(key, out result); + ``` + - `TryGetSequence(...)` likewise: + ```csharp + internal static bool TryGetSequence(TemplateMapping parent, string key, out TemplateSequence result) + => parent.TryGetSequence(key, out result); + ``` + - `GetStringValue(YamlMappingNode node, string key)` becomes: + ```csharp + internal static string? GetStringValue(TemplateMapping node, string key) => node.GetScalar(key); + ``` + - Every other helper keeps its exact signature and logic, swapping `YamlMappingNode` → `TemplateMapping`. They already call `GetStringValue(node, key)` internally, so only the parameter type changes. + - `ConvertMappingToDictionary(YamlMappingNode mapping, int depth)` — rewrite the iteration to use neutral nodes: + ```csharp + internal static IReadOnlyDictionary ConvertMappingToDictionary(TemplateMapping mapping, int depth = 0) + { + if (depth > 10) + throw new InvalidOperationException("Options nesting depth exceeded (max 10)."); + + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var key in mapping.Keys) + { + mapping.TryGet(key, out var valueNode); + dict[key] = valueNode switch + { + TemplateScalar scalar => scalar.Value ?? string.Empty, + TemplateMapping nested => ConvertMappingToDictionary(nested, depth + 1), + TemplateSequence seq => ConvertSequenceToList(seq, depth + 1), + _ => string.Empty + }; + } + return dict; + } + ``` + - `ConvertSequenceToList(YamlSequenceNode sequence, int depth)` → `TemplateSequence`, iterate `sequence.Items`, type checks `TemplateScalar`/`TemplateMapping`/`TemplateSequence`. +- Keep `GetIntValue`, `GetFloatValue`, `GetBoolValue`, `GetNullableIntValue`, `GetNullableBoolValue`, `GetNullableFloatValue`, `GetDoubleValue`, `ContainsExpression`, all `GetExpr*` methods — signatures change only `YamlMappingNode` → `TemplateMapping`; bodies unchanged (they delegate to `GetStringValue`). + +- [ ] **Step 2: Move `KnownProperties.cs` → Core `Parsing/Engine/KnownProperties.cs`** + +- Replace `using YamlDotNet.RepresentationModel;` with `using FlexRender.Parsing.Nodes;`. +- Keep namespace `FlexRender.Parsing`. +- Rewrite `Validate(YamlMappingNode node, string elementType)` key iteration (this is the typo-validation path — verify carefully): + ```csharp + internal static void Validate(TemplateMapping node, string elementType) + { + if (!Registry.TryGetValue(elementType, out var knownProperties)) + return; + + List? unknown = null; + + foreach (var keyName in node.Keys) + { + if (string.Equals(keyName, TypeKey, StringComparison.Ordinal)) + continue; + + if (!knownProperties.Contains(keyName)) + { + unknown ??= []; + unknown.Add(keyName); + } + } + + if (unknown is { Count: > 0 }) + { + var unknownList = string.Join(", ", unknown.Select(u => $"'{u}'")); + var suggestion = BuildSuggestion(unknown, knownProperties); + var message = $"Unknown properties on '{elementType}' element: [{unknownList}]."; + if (suggestion.Length > 0) + message += $" Did you mean: {suggestion}?"; + throw new TemplateParseException(message); + } + } + ``` + Note: neutral `Keys` are already non-null `string`, so the old `YamlScalarNode`/null guards are dropped. `BuildSuggestion`, `LevenshteinDistance`, `BuildSet`, and all `HashSet` registry fields are unchanged. + +- [ ] **Step 3: Move `ElementParsers.cs` → Core `Parsing/Engine/ElementParsers.cs`** + +- Replace `using YamlDotNet.RepresentationModel;` with `using FlexRender.Parsing.Nodes;`. Keep `using static FlexRender.Parsing.NodePropertyHelpers;` (renamed from `YamlPropertyHelpers`). Keep namespace `FlexRender.Parsing`. +- Apply substitutions: + - Field/ctor delegate `Func` → `Func`. + - Every method parameter `YamlMappingNode node` → `TemplateMapping node`; `YamlSequenceNode` → `TemplateSequence`. + - `ParseFlexElement` children loop: + ```csharp + if (TryGetSequence(node, "children", out var childrenNode)) + { + foreach (var child in childrenNode.Items) + { + if (child is TemplateMapping childMapping) + flex.AddChild(_parseElement(childMapping)); + } + } + ``` + - `ParseIfElement` elseIf branch: + ```csharp + if (node.TryGet("elseIf", out var elseIfNode) && elseIfNode is TemplateMapping elseIfMapping) + elseIf = (IfElement)ParseIfElement(elseIfMapping); + ``` + - `ParseStringArray(YamlSequenceNode)` → `TemplateSequence`, iterate `.Items`, `child is TemplateScalar scalar`. + - `ParseTableColumns` / `ParseTableRows`: `YamlSequenceNode` → `TemplateSequence`, `.Children` → `.Items`, `child is not YamlMappingNode` → `child is not TemplateMapping`. + - `ParseChildren(YamlMappingNode node, string key)`: + ```csharp + private IReadOnlyList ParseChildren(TemplateMapping node, string key) + { + if (!node.TryGet(key, out var childrenNode) || childrenNode is not TemplateSequence sequence) + return Array.Empty(); + + var elements = new List(sequence.Items.Count); + foreach (var child in sequence.Items) + { + if (child is TemplateMapping elementNode) + elements.Add(_parseElement(elementNode)); + } + return elements; + } + ``` + - `ParseConditionOperator`, `ParseTextElement`, `ParseQrElement`, etc.: only the `node` parameter type changes; all helper calls (`GetStringValue`, `GetExpr*`, `TryGetSequence`, `TryGetMapping`, `ConvertMappingToDictionary`) are unchanged because the helpers were moved with matching signatures. + - `ParseContentElement`: `YamlPropertyHelpers.ConvertMappingToDictionary(optionsNode)` → `NodePropertyHelpers.ConvertMappingToDictionary(optionsNode)`. + +- [ ] **Step 4: Move `ShapeParsers.cs` → Core `Parsing/Engine/ShapeParsers.cs`** + +- Replace YAML using with `using FlexRender.Parsing.Nodes;`. Keep `using static ...NodePropertyHelpers;` and namespace `FlexRender.Parsing`. Keep `public static class ShapeParsers`. +- `ConvertGradientObjectToCss(YamlMappingNode node)` → `(TemplateMapping node)`. The `colors` loop: `colorsSeq.Children` → `colorsSeq.Items`, `item is YamlScalarNode scalar` → `item is TemplateScalar scalar`. +- `ParseFill(YamlMappingNode)` → `(TemplateMapping)`. +- `ParseRectElement`/`ParseCircleElement`/`ParseEllipseElement`/`ParseDrawElement`/`ParseDrawShape`: `YamlMappingNode` → `TemplateMapping`, `.Children` → `.Items` where iterating `shapesSeq`. +- `ParsePoints(YamlMappingNode)`: tuple checks `item is not YamlSequenceNode pair` → `item is not TemplateSequence pair`, `pair.Children` → `pair.Items`, `... is not YamlScalarNode xScalar` → `... is not TemplateScalar xScalar`. +- `GetFiniteFloatValue(YamlMappingNode ...)` → `(TemplateMapping ...)`. + +- [ ] **Step 5: Move `ChartParsers.cs` → Core `Parsing/Engine/ChartParsers.cs`** + +- Replace YAML using with `using FlexRender.Parsing.Nodes;`. Keep `using FlexRender.Charts;`, `using static ...NodePropertyHelpers;`, namespace `FlexRender.Parsing`, `public static class ChartParsers`. +- All `YamlMappingNode` → `TemplateMapping`, `YamlSequenceNode` → `TemplateSequence`, `YamlNode` → `TemplateNode`. +- `.Children` → `.Items` everywhere (`seriesSeq.Children`, `dataSeq.Children`, `tuple.Children`, `seq.Children`). +- `item is YamlScalarNode scalar` → `item is TemplateScalar scalar`; `dataSeq.Children[0] is YamlSequenceNode` → `dataSeq.Items[0] is TemplateSequence`; `item is not YamlMappingNode seriesNode` → `item is not TemplateMapping seriesNode`; `item is not YamlSequenceNode tuple` → `item is not TemplateSequence tuple`. +- `ParseTupleScalar(YamlNode node, string? label)` → `(TemplateNode node, string? label)`, `node is YamlScalarNode scalar` → `node is TemplateScalar scalar`, error message `(node as YamlScalarNode)?.Value` → `(node as TemplateScalar)?.Value`. +- `ChartElement.MaxDataPointsPerSeries` access is now intra-assembly (Core) — no change needed. + +- [ ] **Step 6: Create `TemplateEngine.cs` in Core (the relocated document-root parse + dispatch)** + +Create `src/FlexRender.Core/Parsing/Engine/TemplateEngine.cs`. This holds everything from the old `TemplateParser` EXCEPT the YAML string/stream/file loading (which stays in the Yaml facade). Keep namespace `FlexRender.Parsing`. + +```csharp +using FlexRender.Charts; +using FlexRender.Configuration; +using FlexRender.Layout; +using FlexRender.Parsing.Ast; +using FlexRender.Parsing.Nodes; +using static FlexRender.Parsing.NodePropertyHelpers; + +namespace FlexRender.Parsing; + +/// +/// Format-neutral template parsing engine. Consumes a neutral +/// document root (produced by the YAML or XML node converters) and builds a AST. +/// Shared by FlexRender.Yaml and FlexRender.Xml; depends only on Core types. +/// +public sealed class TemplateEngine +{ + private readonly Dictionary> _elementParsers; + private readonly ResourceLimits _limits; + private readonly ElementParsers _parsers; + + /// Gets the supported element type names. + public IReadOnlyCollection SupportedElementTypes => _elementParsers.Keys; + + /// Initializes a new engine with the given resource limits. + /// The resource limits to apply. + /// Thrown when is null. + public TemplateEngine(ResourceLimits limits) + { + ArgumentNullException.ThrowIfNull(limits); + _limits = limits; + _parsers = new ElementParsers(ParseElement); + _elementParsers = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["text"] = ElementParsers.ParseTextElement, + ["flex"] = _parsers.ParseFlexElement, + ["qr"] = ElementParsers.ParseQrElement, + ["barcode"] = ElementParsers.ParseBarcodeElement, + ["image"] = ElementParsers.ParseImageElement, + ["separator"] = ElementParsers.ParseSeparatorElement, + ["each"] = _parsers.ParseEachElement, + ["if"] = _parsers.ParseIfElement, + ["table"] = ElementParsers.ParseTableElement, + ["svg"] = ElementParsers.ParseSvgElement, + ["content"] = ElementParsers.ParseContentElement, + ["rect"] = ShapeParsers.ParseRectElement, + ["circle"] = ShapeParsers.ParseCircleElement, + ["ellipse"] = ShapeParsers.ParseEllipseElement, + ["draw"] = node => ShapeParsers.ParseDrawElement(node, _limits.MaxShapesPerDraw), + ["chart"] = node => ChartParsers.ParseChartElement(node, _limits.MaxSeriesPerChart, _limits.MaxDataPointsPerSeries) + }; + } + + /// Builds a from a neutral document-root mapping. + /// The document root mapping (from a format converter). + /// The parsed template. + /// Thrown when is null. + /// Thrown when required sections are missing or invalid. + public Template ParseDocumentRoot(TemplateMapping root) + { + ArgumentNullException.ThrowIfNull(root); + + var template = new Template(); + + if (TryGetMapping(root, "template", out var templateNode)) + { + template.Name = GetStringValue(templateNode, "name"); + template.Version = GetIntValue(templateNode, "version", 1); + template.Culture = GetStringValue(templateNode, "culture"); + } + + if (root.TryGet("fonts", out var fontsNode)) + { + template.Fonts = fontsNode switch + { + TemplateMapping fontsMapping => ParseFonts(fontsMapping), + TemplateSequence fontsSequence => ParseFontsList(fontsSequence), + _ => throw new TemplateParseException( + "Invalid 'fonts' section. Expected a mapping (name: path) or a list of font entries.") + }; + } + + if (!TryGetMapping(root, "canvas", out var canvasNode)) + throw new TemplateParseException("Missing required 'canvas' section"); + + template.Canvas = ParseCanvas(canvasNode); + + if (TryGetSequence(root, "layout", out var layoutNode)) + template.Elements = ParseElements(layoutNode); + + return template; + } + + // --- The following are moved VERBATIM from the old TemplateParser, retyped to neutral nodes: --- + // ParseCanvas(TemplateMapping) — body unchanged (uses GetStringValue/GetIntValue helpers). + // ParseFonts(TemplateMapping) — rewrite the entry loop (see Step 6a below). + // ParseFontsList(TemplateSequence) — rewrite item loop (see Step 6b below). + // ParseElements(TemplateSequence) — iterate .Items, child is TemplateMapping. + // ParseElement(TemplateMapping) — unchanged except parameter type; calls KnownProperties.Validate(node, type). +} +``` + +- [ ] **Step 6a: Port `ParseCanvas`, `ParseElements`, `ParseElement` into `TemplateEngine`** + +Copy them from the old `TemplateParser.cs`, changing only parameter types: +- `ParseCanvas(YamlMappingNode node)` → `private static CanvasSettings ParseCanvas(TemplateMapping node)` — body identical. +- `ParseElements(YamlSequenceNode node)` → + ```csharp + private List ParseElements(TemplateSequence node) + { + var elements = new List(node.Items.Count); + foreach (var child in node.Items) + if (child is TemplateMapping elementNode) + elements.Add(ParseElement(elementNode)); + return elements; + } + ``` +- `ParseElement(YamlMappingNode node)` → `private TemplateElement ParseElement(TemplateMapping node)` — body identical (calls `KnownProperties.Validate(node, type)` and the parser dict). + +- [ ] **Step 6b: Port `ParseFonts` and `ParseFontsList` (entry/key iteration rewrite)** + +`ParseFonts` — the old code iterates `node.Children` reading `entry.Key`/`entry.Value`. Rewrite: +```csharp +private static Dictionary ParseFonts(TemplateMapping node) +{ + var fonts = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var fontName in node.Keys) + { + if (string.IsNullOrEmpty(fontName)) + continue; + + node.TryGet(fontName, out var value); + FontDefinition fontDef; + switch (value) + { + case TemplateScalar scalarValue: + fontDef = new FontDefinition(scalarValue.Value ?? string.Empty); + break; + case TemplateMapping mappingValue: + var path = GetStringValue(mappingValue, "path") ?? string.Empty; + var fallback = GetStringValue(mappingValue, "fallback"); + fontDef = new FontDefinition(path, fallback); + break; + default: + throw new TemplateParseException( + $"Invalid font definition for '{fontName}'. Expected a string path or object with 'path' and optional 'fallback' properties."); + } + fonts[fontName] = fontDef; + } + return fonts; +} +``` + +`ParseFontsList(YamlSequenceNode node)` → `(TemplateSequence node)`: iterate `node.Items`; `case YamlScalarNode scalar` → `case TemplateScalar scalar`; `case YamlMappingNode mapping` → `case TemplateMapping mapping`. All the name/default/index logic is unchanged. + +- [ ] **Step 7: Rewrite the Yaml `TemplateParser.cs` facade** + +Replace `src/FlexRender.Yaml/Parsing/TemplateParser.cs` with the facade below. It keeps the same public API (`Parse(string)`, `Parse(Stream)`, `ParseFile`, `ParseFileAsync`, `MaxFileSize`, `SupportedElementTypes`, both ctors) and the test shim `ParseDocumentRootForTests` is REMOVED here (it moves to a Core-typed test helper — see Task 6). YamlDotNet stays here only for loading. + +```csharp +using FlexRender.Abstractions; +using FlexRender.Configuration; +using FlexRender.Parsing.Ast; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace FlexRender.Parsing; + +/// +/// Parses YAML templates into the AST. Loads YAML via YamlDotNet, +/// converts it to the format-neutral node model, then delegates to the shared +/// for all element parsing and validation. +/// +public sealed class TemplateParser : ITemplateParser +{ + /// + /// Maximum allowed file size in bytes (1 MB). Preserved for backward compatibility; + /// the runtime limit comes from . + /// + public const long MaxFileSize = 1024 * 1024; + + private readonly ResourceLimits _limits; + private readonly TemplateEngine _engine; + + /// Gets the supported element type names. + public IReadOnlyCollection SupportedElementTypes => _engine.SupportedElementTypes; + + /// Initializes a new parser with default resource limits. + public TemplateParser() : this(new ResourceLimits()) + { + } + + /// Initializes a new parser with custom resource limits. + /// The resource limits to apply. + /// Thrown when is null. + public TemplateParser(ResourceLimits limits) + { + ArgumentNullException.ThrowIfNull(limits); + _limits = limits; + _engine = new TemplateEngine(limits); + } + + /// Parses a YAML string into a AST. + /// The YAML string. + /// The parsed template. + /// Thrown when parsing fails. + public Template Parse(string content) + { + if (string.IsNullOrWhiteSpace(content)) + throw new TemplateParseException("Template YAML is empty or whitespace"); + + YamlMappingNode root; + try + { + var yamlStream = new YamlStream(); + using var reader = new StringReader(content); + yamlStream.Load(reader); + + if (yamlStream.Documents.Count == 0) + throw new TemplateParseException("Template YAML is empty"); + + root = (YamlMappingNode)yamlStream.Documents[0].RootNode; + } + catch (YamlException ex) + { + throw new TemplateParseException($"Invalid YAML: {ex.Message}", ex); + } + + return _engine.ParseDocumentRoot(YamlNodeConverter.Convert(root)); + } + + /// Parses a YAML template from a stream. + /// The stream containing YAML content. + /// The parsed template. + /// Thrown when is null. + /// Thrown when parsing fails. + public Template Parse(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + using var reader = new StreamReader(stream); + return Parse(reader.ReadToEnd()); + } + + /// Asynchronously parses a YAML file into a AST. + /// The path to the YAML file. + /// Cancellation token. + /// The parsed template. + /// Thrown when the file does not exist. + /// Thrown when parsing fails or the file exceeds the maximum size. + public Task