16 KiB
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
// ============================================================================
// 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
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:
- First line: detect
horizontalflag fromxychart-beta horizontal - Iterate remaining lines, match each directive:
title "..."→ extract quoted stringx-axis [...]→ parse as categorical labels (split by comma, trim quotes)x-axis <num> --> <num>→ parse as numeric rangex-axis "Title" [...]orx-axis "Title" <num> --> <num>→ title + datay-axis— same patterns as x-axisbar [...]→ parse numeric array, push{ type: 'bar', data }line [...]→ parse numeric array, push{ type: 'line', data }
- 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 quotingparseRange(str: string): { min: number; max: number } | null
3. src/xychart/layout.ts
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<PositionedXYChart>
Layout algorithm:
-
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
-
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.maxtoplotArea.y + plotArea.height .. plotArea.y(inverted for SVG) - For categorical x-axis: even spacing with
bandWidth = plotArea.width / categories.length
- X-scale: Maps data indices (categorical) or numeric range to
-
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
-
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
-
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) }
-
Line polylines:
- For each line series: one point per data value at
(xCenter, scale(value))
- For each line series: one point per data value at
-
Grid lines:
- Horizontal lines at each y-axis tick, spanning the plot width
-
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:
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
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:
svgOpenTag()— sets CSS variables on the root<svg>elementbuildStyleBlock(font, false)— no mono font needed for charts- Additional
<style>rules for chart-specific elements:.xychart-bar { fill: var(--_accent); }(or series-indexed colors).xychart-line { stroke: var(--_accent); fill: none; stroke-width: 2; }.xychart-grid { stroke: var(--_inner-stroke); stroke-dasharray: 3 3; }.xychart-axis { stroke: var(--_line); }.xychart-dot { fill: var(--_accent); }(data points on lines)
- Grid lines (behind data, using
var(--_inner-stroke)) - Bar rectangles (using
var(--_accent)or series color palette) - Line polylines with data point circles
- Axis lines and tick marks (using
var(--_line)) - Axis labels (using
var(--_text-muted)) - Axis titles (using
var(--_text-sec)) - Chart title (using
var(--_text), centered, bold)
Theming integration:
The chart uses the same CSS custom property system as all other diagram types. Mapping to chart elements:
| CSS Variable | Chart Element |
|---|---|
var(--_text) |
Chart title |
var(--_text-sec) |
Axis titles |
var(--_text-muted) |
Axis tick labels |
var(--_line) |
Axis lines, tick marks |
var(--_inner-stroke) |
Grid lines |
var(--_accent) |
Bars (primary), line strokes |
var(--_node-fill) |
Bar fill (alternative — lighter) |
var(--_node-stroke) |
Bar stroke |
var(--bg) |
Background / label backgrounds |
Multi-series coloring:
For charts with multiple series, generate palette colors using color-mix():
.xychart-series-0 { --_series: var(--_accent); }
.xychart-series-1 { --_series: color-mix(in srgb, var(--_accent) 60%, var(--fg)); }
.xychart-series-2 { --_series: color-mix(in srgb, var(--_accent) 40%, var(--fg)); }
Files to Modify
1. src/index.ts
Add xychart detection in detectDiagramType() and import the pipeline:
// Add to imports:
import { parseXYChart } from './xychart/parser.ts'
import { layoutXYChart } from './xychart/layout.ts'
import { renderXYChartSvg } from './xychart/renderer.ts'
// Update return type:
function detectDiagramType(text: string): 'flowchart' | 'sequence' | 'class' | 'er' | 'xychart' {
const firstLine = text.trim().split(/[\n;]/)[0]?.trim().toLowerCase() ?? ''
if (/^xychart-beta\b/.test(firstLine)) return 'xychart'
if (/^xychart\b/.test(firstLine)) return 'xychart'
if (/^sequencediagram\s*$/.test(firstLine)) return 'sequence'
if (/^classdiagram\s*$/.test(firstLine)) return 'class'
if (/^erdiagram\s*$/.test(firstLine)) return 'er'
return 'flowchart'
}
// Add case in renderMermaid switch:
case 'xychart': {
const chart = parseXYChart(lines)
const positioned = await layoutXYChart(chart, options)
return renderXYChartSvg(positioned, colors, font, transparent)
}
Note: xychart-beta detection must use \b (word boundary) rather than \s*$ because the first line may contain horizontal after the keyword.
2. samples-data.ts
Add xychart samples in a new 'XY Chart' category section. Examples should cover:
- Basic bar chart (categorical x-axis)
- Basic line chart
- Combined bar + line
- Horizontal orientation
- Numeric x-axis with range
- Axis titles
- Chart title
- Large data sets
- Single data point
- Negative values
- Theme variants
3. src/browser.ts
No changes needed — the browser bundle imports from src/index.ts which re-exports renderMermaid. Since xychart is handled inside renderMermaid, the browser bundle will automatically support xychart diagrams.
Key Patterns (from existing pipelines)
Parse → Layout → Render Pipeline
Every diagram type follows the same three-stage pipeline:
-
Parser (
parser.ts): Takesstring[](preprocessed lines, comments stripped, semicolons split). Returns a typed AST (e.g.,ErDiagram,SequenceDiagram). Pure function, no side effects. -
Layout (
layout.ts): Takes the parsed AST +RenderOptions. Returns a positioned structure with pixel coordinates. Async (for API consistency even when synchronous internally). UsesestimateTextWidth()for sizing. ER/class/flowchart use dagre; sequence uses direct coordinate computation. -
Renderer (
renderer.ts): Takes the positioned structure +DiagramColors+ font + transparent flag. Returns an SVG string. UsessvgOpenTag()andbuildStyleBlock()fromtheme.ts. All colors via CSS custom properties (var(--_xxx)).
Theming via CSS Custom Properties
- Two required variables:
--bg,--fg— set as inline styles on the<svg>tag - Five optional enrichment variables:
--line,--accent,--muted,--surface,--border - Derived internal variables (
--_text,--_line,--_node-fill, etc.) computed in<style>usingcolor-mix()fallbacks - Renderers never hardcode colors — always reference
var(--_xxx)
Text Width Estimation
estimateTextWidth(text, fontSize, fontWeight)— proportional fonts (Inter)estimateMonoTextWidth(text, fontSize)— monospace fonts- Used in layout to size boxes, compute margins, center labels
- Constants in
styles.ts:FONT_SIZES,FONT_WEIGHTS,STROKE_WIDTHS,TEXT_BASELINE_SHIFT
SVG Structure Pattern
<svg ...style="--bg:...;--fg:...">
<style>
@import url(...)
text { font-family: ... }
svg { --_text: ...; --_line: ...; ... }
</style>
<defs>...</defs>
<!-- background elements (grid, relationship lines) -->
<!-- primary elements (bars, boxes, nodes) -->
<!-- foreground elements (labels, markers) -->
</svg>
All SVG is generated as string concatenation (no DOM). Parts are pushed to an array and joined with \n.
Integration Checklist
src/xychart/types.ts— type definitionssrc/xychart/parser.ts— parse xychart-beta textsrc/xychart/layout.ts— compute positionssrc/xychart/renderer.ts— render SVGsrc/index.ts— add detection + routingsamples-data.ts— add xychart samples- Tests — parser unit tests for each syntax variant
Design Decisions
No dagre dependency
XY charts use a fixed coordinate-space layout. Unlike ER/class/flowchart diagrams that need graph layout algorithms, charts have a predetermined structure: axes form a fixed frame, data maps linearly to pixel coordinates. Direct computation is simpler, faster, and avoids the dagre dependency.
Categorical vs numeric x-axis
The parser detects which variant is used: [label1, label2, ...] → categorical, min --> max → numeric range. The layout handles both via a unified scale function that maps data indices or numeric values to pixel positions.
Series palette via color-mix
Rather than hardcoding a palette, multi-series charts derive colors from the theme's --accent using color-mix(). This ensures the chart looks correct in any theme without maintaining separate palettes.
Horizontal mode as coordinate swap
Horizontal orientation is handled by swapping x/y coordinates during layout rather than having a separate rendering path. The renderer draws the same SVG elements regardless of orientation — only the positions differ.