# XY Chart (xychart-beta) — Phase 2 Design Document ## Overview This document specifies the architecture for adding `xychart-beta` / `xychart` support to beautiful-mermaid. The implementation follows the same parse → layout → render pipeline used by all other diagram types (ER, sequence, class, flowchart). XY charts render bar charts, line charts, or combinations thereof, with categorical or numeric x-axes, numeric y-axes, optional titles, and optional horizontal orientation. --- ## Mermaid Syntax Reference ``` xychart-beta title "Sales Revenue" x-axis [jan, feb, mar, apr, may, jun] y-axis "Revenue (USD)" 4000 --> 11000 bar [5000, 6000, 7500, 8200, 9800, 10500] line [5000, 6000, 7500, 8200, 9800, 10500] ``` Horizontal variant: ``` xychart-beta horizontal title "Sales Revenue" x-axis [jan, feb, mar, apr, may, jun] y-axis "Revenue (USD)" 4000 --> 11000 bar [5000, 6000, 7500, 8200, 9800, 10500] ``` Numeric x-axis variant: ``` xychart-beta x-axis 0 --> 100 y-axis 0 --> 50 line [10, 20, 35, 40, 45] ``` --- ## Files to Create ### 1. `src/xychart/types.ts` ```typescript // ============================================================================ // XY Chart types // // Models the parsed and positioned representations of a Mermaid xychart-beta // diagram. Supports bar charts, line charts, and combinations with categorical // or numeric x-axes. // ============================================================================ /** Parsed XY chart — logical structure from mermaid text */ export interface XYChart { /** Optional chart title */ title?: string /** Chart orientation: vertical (default) or horizontal */ horizontal: boolean /** X-axis configuration */ xAxis: XYAxis /** Y-axis configuration */ yAxis: XYAxis /** Data series (bar and/or line) */ series: XYChartSeries[] } /** Axis configuration — categorical (labels) or numeric (range) */ export interface XYAxis { /** Optional axis title/label */ title?: string /** Categorical labels (e.g., ["jan", "feb", "mar"]) — mutually exclusive with range */ categories?: string[] /** Numeric range — mutually exclusive with categories */ range?: { min: number; max: number } } /** A single data series (bar or line) */ export interface XYChartSeries { /** Series type */ type: 'bar' | 'line' /** Data values — one per category, or evenly spaced across numeric range */ data: number[] } // ============================================================================ // Positioned XY chart — ready for SVG rendering // ============================================================================ export interface PositionedXYChart { width: number height: number /** Title text and position (if present) */ title?: PositionedTitle /** Positioned x-axis with tick marks and labels */ xAxis: PositionedAxis /** Positioned y-axis with tick marks and labels */ yAxis: PositionedAxis /** The plot area bounds (inside axes) */ plotArea: PlotArea /** Positioned bar groups */ bars: PositionedBar[] /** Positioned line polylines */ lines: PositionedLine[] /** Horizontal grid lines for readability */ gridLines: GridLine[] } export interface PositionedTitle { text: string x: number y: number } export interface PositionedAxis { /** Optional axis title text and position */ title?: { text: string; x: number; y: number; rotate?: number } /** Tick positions along the axis */ ticks: AxisTick[] /** Axis line: start and end coordinates */ line: { x1: number; y1: number; x2: number; y2: number } } export interface AxisTick { /** Label text for this tick */ label: string /** Position of the tick mark on the axis */ x: number y: number /** End of the tick mark (short perpendicular line) */ tx: number ty: number /** Label anchor position */ labelX: number labelY: number /** Text anchor for label ("middle", "end", "start") */ textAnchor: 'start' | 'middle' | 'end' } export interface PlotArea { x: number y: number width: number height: number } export interface PositionedBar { /** Bar rectangle in SVG coordinates */ x: number y: number width: number height: number /** Original data value (for tooltips/labels if needed) */ value: number /** Series index (for coloring multiple bar series) */ seriesIndex: number } export interface PositionedLine { /** Polyline points */ points: Array<{ x: number; y: number }> /** Series index (for coloring multiple line series) */ seriesIndex: number } export interface GridLine { x1: number y1: number x2: number y2: number } ``` ### 2. `src/xychart/parser.ts` ```typescript import type { XYChart, XYAxis, XYChartSeries } from './types.ts' /** * Parse a Mermaid xychart-beta diagram. * Expects the first line to be "xychart-beta" (optionally followed by "horizontal"). * * Supported directives (order-independent after header): * title "Chart Title" * x-axis [label1, label2, ...] — categorical * x-axis min --> max — numeric range * x-axis "Axis Title" [label1, ...] — with title * y-axis min --> max — numeric range * y-axis "Axis Title" min --> max — with title * bar [val1, val2, ...] * line [val1, val2, ...] */ export function parseXYChart(lines: string[]): XYChart ``` **Parsing strategy:** 1. First line: detect `horizontal` flag from `xychart-beta horizontal` 2. Iterate remaining lines, match each directive: - `title "..."` → extract quoted string - `x-axis [...]` → parse as categorical labels (split by comma, trim quotes) - `x-axis --> ` → parse as numeric range - `x-axis "Title" [...]` or `x-axis "Title" --> ` → title + data - `y-axis` — same patterns as x-axis - `bar [...]` → parse numeric array, push `{ type: 'bar', data }` - `line [...]` → parse numeric array, push `{ type: 'line', data }` 3. If no y-axis range is specified, auto-derive from min/max of all series data (with padding) **Internal helpers:** - `parseNumericArray(str: string): number[]` — parse `[1, 2, 3]` to `[1, 2, 3]` - `parseCategoryArray(str: string): string[]` — parse `[jan, feb, "mar apr"]` with optional quoting - `parseRange(str: string): { min: number; max: number } | null` ### 3. `src/xychart/layout.ts` ```typescript import type { XYChart, PositionedXYChart } from './types.ts' import type { RenderOptions } from '../types.ts' /** * Lay out a parsed XY chart by computing pixel coordinates. * No dagre needed — direct coordinate-space mapping. * * Layout is synchronous but kept async for API consistency. */ export async function layoutXYChart( chart: XYChart, options: RenderOptions = {} ): Promise ``` **Layout algorithm:** 1. **Canvas sizing:** - Default plot area: 600 x 400px (tunable via constants) - Padding: `options.padding ?? 40` - Reserve space for title (if present): ~30px top - Reserve space for axis labels: ~50px bottom (x-axis), ~60px left (y-axis) - Reserve space for axis titles: ~20px each side if present 2. **Scale computation:** - X-scale: Maps data indices (categorical) or numeric range to `plotArea.x .. plotArea.x + plotArea.width` - Y-scale: Maps `yAxis.range.min .. yAxis.range.max` to `plotArea.y + plotArea.height .. plotArea.y` (inverted for SVG) - For categorical x-axis: even spacing with `bandWidth = plotArea.width / categories.length` 3. **Y-axis auto-range** (when not specified): - Collect all data values across all series - `min = Math.min(...allValues)`, `max = Math.max(...allValues)` - Add 10% padding: `min - 0.1 * range`, `max + 0.1 * range` - Round to nice tick values 4. **Tick generation:** - X-axis categorical: one tick per category, centered in band - X-axis numeric: 5-10 ticks at "nice" intervals (1, 2, 5, 10, 20, 50, ...) - Y-axis: 5-8 ticks at nice intervals 5. **Bar rectangles:** - `barWidth = bandWidth * 0.6` (60% of band, leaving gaps) - Multiple bar series: subdivide band width equally - Each bar: `{ x, y: scale(value), width: barWidth, height: plotBottom - scale(value) }` 6. **Line polylines:** - For each line series: one point per data value at `(xCenter, scale(value))` 7. **Grid lines:** - Horizontal lines at each y-axis tick, spanning the plot width 8. **Horizontal mode:** - Swap x and y throughout: categories go on y-axis (top-to-bottom), values on x-axis (left-to-right) - Bars become horizontal rectangles - Line polylines have swapped coordinates **Layout constants:** ```typescript const XY = { padding: 40, plotWidth: 600, plotHeight: 400, titleFontSize: 16, titleHeight: 30, axisLabelFontSize: 11, axisTitleFontSize: 12, xLabelHeight: 50, yLabelWidth: 60, axisTitlePad: 20, tickLength: 5, barPadRatio: 0.2, // gap between bars as fraction of band barGroupPadRatio: 0.1, // gap between bars within a group gridDash: '3 3', } as const ``` **Text width estimation:** Uses `estimateTextWidth()` from `../styles.ts` for: - Chart title width (to center it) - Y-axis label widths (to determine left margin) - X-axis label widths (to check for overlap / rotation) ### 4. `src/xychart/renderer.ts` ```typescript import type { PositionedXYChart } from './types.ts' import type { DiagramColors } from '../theme.ts' /** * Render a positioned XY chart as an SVG string. */ export function renderXYChartSvg( chart: PositionedXYChart, colors: DiagramColors, font?: string, transparent?: boolean ): string ``` **SVG structure and render order:** 1. `svgOpenTag()` — sets CSS variables on the root `` element 2. `buildStyleBlock(font, false)` — no mono font needed for charts 3. Additional `