# XY charts

> Page: https://docs.plotwork.dev/xy-charts. Index of every page: https://docs.plotwork.dev/llms.txt

An XY chart plots numbers against places: latency by week, CPU by hour, cost by month, p95 by endpoint. Plotwork draws it with bars, lines, and areas on one value axis, a labelled reference line for the limit that matters, and a callout for the event that explains a change. It is for anyone who writes a design document with metrics in it, and for an agent that has been asked to chart them without making the chart lie.

## TL;DR

| Question | Answer |
|---|---|
| What is it for? | Numbers over a few to a few dozen places: latency, capacity, throughput, error budgets, growth, and cost. Not for parts of one whole (use a pie chart) or for two measures of different scale (draw two charts). |
| How do I start one? | Write `xychart "Title"`, then `x "Week" ["W1" "W2" "W3"]` for the places, then one `bar`, `line`, or `area` line for each series: `line "p95" [310 360 345]`. |
| What does the picture show? | A rounded plate with the title in a corner tab, quiet gridlines, bars with rounded tops, lines with round joins and an end dot, soft areas, and a dashed line for each threshold. |
| How are series named? | Up to four are named by a pill beside their last point, in the series' own tone mark. More than four get a legend above the plot. One series under a titled axis is not named twice. |
| What do I never choose? | A color. The series take the tones of the theme in a fixed, color-blind-safe order. You may choose a tone for a series, and a threshold is red until you say otherwise. |
| How is it kept honest? | Bars and areas start at zero, ticks are on round numbers, numbers carry thousands marks and their unit, and lint warns for a baseline above zero, more than 6 series, negative bars, a threshold the axis cannot hold, and crowded labels. |
| What does Mermaid convert? | `xychart-beta` and `xychart`, with a title, both axes, `bar`, `line`, and `horizontal`. See [From Mermaid](#from-mermaid). |
| How big can it be? | At most 60 places, 8 series, 4 thresholds, and 6 notes. |

## Ask your agent

```text
Use Plotwork to draw an XY chart of the p50 and p95 latency of our checkout API over the last eight weeks. p95 by week: 310, 330, 345, 420, 455, 440, 350, 335. p50 by week: 120, 125, 128, 160, 170, 165, 135, 130. Draw p95 as bars, p50 as a line, our 400 ms objective as a red line, and note the 2.3 release in week 4 and the cache fix in week 7.
```

```text
Use Plotwork to draw a stacked bar chart of our cloud cost per month in dollars, January to June, split into compute, storage, network, and other. Start the axis at zero.
```

```text
Use Plotwork to draw a sideways bar chart that ranks our six slowest endpoints by p95 latency in milliseconds, slowest first, with the 400 ms objective as a line.
```

## Example

Eight weeks of latency against an objective (the SLO, a service level objective). The p95, the 95th percentile of latency, is drawn as bars, so each week that crosses the 400 ms line is red, and the p50, the median, is a line. Each series is named beside its last point, the objective is named at the end of its line, and two notes mark what changed.

**Diagram source**

```diagram
xychart "API latency against the SLO" desc="Weekly p50 and p95 latency of the checkout API against its 400 ms objective: p95 crossed the line after release 2.3 and came back under it after the cache fix."

x "Week" ["W1" "W2" "W3" "W4" "W5" "W6" "W7" "W8"]
y "Latency" unit="ms" min=0 max=500

bar "p95" [310 330 345 420 455 440 350 335]
line "p50" [120 125 128 160 170 165 135 130]

threshold "SLO 400 ms" 400 tone=red

note "Release 2.3" at="W4"
note "Cache fix" at="W7"
```

*Picture: API latency against the SLO: 2 series (bar and line) over 8 places on the Week axis, on a scale of 0 ms to 500 ms. Weekly p50 and p95 latency of the checkout API against its 400 ms objective: p95 crossed the line after release 2.3 and came back under it after the cache fix.*

**Text description**

**X axis, Week: W1, W2, W3, W4, W5, W6, W7, W8.**

**Y axis, Latency: 0 ms to 500 ms.**

**Drawn with the places along the bottom.**

**Series:**

- p95 (bar): W1 310 ms, W2 330 ms, W3 345 ms, W4 420 ms, W5 455 ms, W6 440 ms, W7 350 ms, W8 335 ms
- p50 (line): W1 120 ms, W2 125 ms, W3 128 ms, W4 160 ms, W5 170 ms, W6 165 ms, W7 135 ms, W8 130 ms

**Thresholds:**

- SLO 400 ms: 400 ms
- Crossed above SLO 400 ms: p95 in W4, W5, and W6

**Notes:**

- W4: Release 2.3
- W7: Cache fix

## The language

One statement to a line. Text is always in double quotes, options are `key=value`, a list is in square brackets, and `#` starts a comment. Write a negative number with its sign in front (`-5`), and put text that holds `+`, `(`, `<`, `#`, or a bare `/` in quotes.

| Statement | Meaning | Example |
|---|---|---|
| `xychart "Title" direction=<vertical\|horizontal> stack=<on\|off> values=<on\|off> desc="<text>"` | The header. Every option is optional, and so is the title. | `xychart "Latency by week" stack=off` |
| `x "Title" ["<place>" ...]` or `x "Title" <from> to <to>` | The horizontal axis, once: named places, or a number range the values are spread over. | `x "Week" ["W1" "W2" "W3"]` |
| `y "Title" unit=<text> min=<number> max=<number>` | The value axis, once. Every part is optional. | `y "Latency" unit="ms" min=0` |
| `bar\|line\|area "Name" [<number> ...] tone=<tone> icon=<icon>` | One series: its name, then one number for each place. | `line "p95" [310 360 345]` |
| `threshold "Label" <number> tone=<tone> breach=<above\|below\|none>` | A labelled reference line across the plot. | `threshold "SLO 400 ms" 400` |
| `note "Text" at="<place>"` | A callout above the plot, with a dotted line down to one place. | `note "Release 2.3" at="W4"` |
| `desc "<text>"` | The plain-language description. The same as `desc=` on the header. | `desc "How latency moved."` |

A series has a name that is unique in any case, and one value for each place on the axis. A value is a plain number with an optional fraction. A thousands mark, a percent sign, a unit, and an exponent are errors, and the hint says what to write: `1200`, not `1,200`, and `40` on a `y` axis with `unit="%"`, not `40%`.

### Options

| Option | On | Values | Default |
|---|---|---|---|
| `direction=` | header | `vertical` (places along the bottom), `horizontal` (places down the side) | `vertical` |
| `stack=` | header | `on`, `off` | `off` |
| `values=` | header | `on`, `off` | `on` |
| `desc=` | header | Quoted text | none |
| `unit=` | `y` | Up to 12 characters, such as `ms`, `%`, `$`, `req/s` | none |
| `min=`, `max=` | `y` | A number, with a sign in front if it is negative | chosen from the data |
| `tone=` | series, threshold | `neutral`, `blue`, `violet`, `aqua`, `orange`, `green`, `amber`, `red` | the next tone of the order below, or `red` for a threshold |
| `icon=` | series | Any bundled icon, such as `lucide:activity`, or `none` | none |
| `breach=` | threshold | `above`, `below`, `none` | `above` |
| `at=` | note | A place on the x axis, or an x value on a number axis | required |

### Bars, lines, and areas

A `bar` draws a bar for each place, up to 28px thick, side by side when there are several bar series. Its top has soft corners and its foot is square. A `line` joins the points with a 2px line, marks each point, and marks the last point larger. An `area` is a line with a wash of its tone under it. Draw the one that matches the question: bars to compare sizes, lines to show a trend, areas for a quantity that accumulates.

The series take `blue`, `orange`, `aqua`, `violet`, `amber`, `green`, `red`, and `neutral` in turn. A `tone=` on a series keeps that tone, and the others take the next ones that nobody has claimed, so two series never share a hue until all eight are in use. Nobody writes a color value: the theme owns every color, in light and in dark, and every mark keeps at least 3:1 against the plate.

### Axes and ticks

Ticks are on round numbers (1, 2, or 5 times a power of ten), and a free end of the axis is pushed out to the next tick so that the data never touches it. Bars and areas always start at zero. A chart of lines alone starts at zero too, unless its smallest value is more than half its largest, where it starts at the tick below the data, because the question is then the change. `min=` and `max=` fix an end exactly, and a `min=` above zero under bars or areas draws a warning.

A symbol unit (`$`, `%`) is written on the numbers, and a word unit (`ms`, `req/s`) goes in the title of the axis in brackets: `Latency (ms)`. Pills, thresholds, and the text description always write the unit in full. Large ticks are compact: `250K`, `1.5M`.

A number axis spreads the values of every series evenly over a range, so each series has the same number of values:

```plotwork
x "Requests per second" 0 to 1000
line "p99" [110 155 330 880 1700]
```

### Naming the series

Up to four series on an upright chart are named by a pill in the gutter at the right of the plot, level with the series' last point, with the last value after the name. A bar series is named level with its last bar, and a stacked series level with its slice. Pills that would touch are spread by the least movement that keeps them apart. The name is in the ink color, with a small mark in the series' tone beside it, because a light hue is not legible as text.

More than four series, and every sideways chart, are named in a legend of pills above the plot, after the title of the axis. A single series under a titled axis is not named at all, since the title says it.

### Thresholds

A threshold is a labelled line across the plot, dashed in its tone, named in a pill at its right end. It is the most useful element of an engineering chart: an objective, a quota, a capacity. Bars and points on the wrong side of it are drawn in its tone, and the text description lists which crossed.

`breach=` chooses the side: `above` for a limit that must not be passed, `below` for a floor, and `none` for a line that is only a reference. The axis grows to hold the line. A stacked bar keeps its own tones, because its segments are not the value the line is about.

### Notes

A note is a pill above the plot with a dotted line down to its place. Two notes that would touch go to separate rows. A note is not available on a sideways chart, where a threshold names a number instead.

### Capacity against a limit

An area for the quantity, a line for a second one, and a limit in amber. The points of the area that cross the limit are marked in its tone, and the symbol unit rides on the ticks.

**Diagram source**

```diagram
xychart "CPU of the API fleet against its limit" desc="Average CPU of the API fleet over a day: it climbs through the morning, crosses the 80 percent limit at lunch time and again in the evening peak, and memory follows it a little below."

x "Hour" ["00" "03" "06" "09" "12" "15" "18" "21"]
y "CPU" unit="%" min=0 max=100

area "CPU" [22 18 31 64 86 78 91 52]
line "Memory" [48 47 50 58 66 69 74 60]

threshold "Limit 80%" 80 tone=amber
```

*Picture: CPU of the API fleet against its limit: 2 series (area and line) over 8 places on the Hour axis, on a scale of 0% to 100%. Average CPU of the API fleet over a day: it climbs through the morning, crosses the 80 percent limit at lunch time and again in the evening peak, and memory follows it a little below.*

**Text description**

**X axis, Hour: 00, 03, 06, 09, 12, 15, 18, 21.**

**Y axis, CPU: 0% to 100%.**

**Drawn with the places along the bottom.**

**Series:**

- CPU (area): 00 22%, 03 18%, 06 31%, 09 64%, 12 86%, 15 78%, 18 91%, 21 52%
- Memory (line): 00 48%, 03 47%, 06 50%, 09 58%, 12 66%, 15 69%, 18 74%, 21 60%

**Thresholds:**

- Limit 80%: 80%
- Crossed above Limit 80%: CPU in 12 and 18

### Stacked bars

`stack=on` stacks the bar series on one another, in the order written, with a gap of the plate between segments. Each stack is named once by its total above it, and each service is named level with its slice. Use it for parts of a total that changes. A stack hides the size of every segment but the lowest, so keep it to a few series.

**Diagram source**

```diagram
xychart "Cloud cost per month by service" stack=on desc="Monthly cloud spend in dollars split by service: compute is the largest and growing, storage is steady, and network spiked in April."

x "Month" ["Jan" "Feb" "Mar" "Apr" "May" "Jun"]
y "Cost" unit="$" min=0

bar "Compute" [4200 4400 4700 5100 5400 5600]
bar "Storage" [1800 1850 1900 1950 2000 2050]
bar "Network" [900 950 1100 2300 1200 1250]
bar "Other" [600 600 650 650 700 700]
```

*Picture: Cloud cost per month by service: 4 series (bar) over 6 places on the Month axis, on a scale of $0 to $10,000. Monthly cloud spend in dollars split by service: compute is the largest and growing, storage is steady, and network spiked in April.*

**Text description**

**X axis, Month: Jan, Feb, Mar, Apr, May, Jun.**

**Y axis, Cost: $0 to $10,000.**

**Drawn with the places along the bottom.**

**Series (the bars are stacked):**

- Compute (bar): Jan $4,200, Feb $4,400, Mar $4,700, Apr $5,100, May $5,400, Jun $5,600
- Storage (bar): Jan $1,800, Feb $1,850, Mar $1,900, Apr $1,950, May $2,000, Jun $2,050
- Network (bar): Jan $900, Feb $950, Mar $1,100, Apr $2,300, May $1,200, Jun $1,250
- Other (bar): Jan $600, Feb $600, Mar $650, Apr $650, May $700, Jun $700
- Bar totals: Jan $7,500, Feb $7,800, Mar $8,350, Apr $10,000, May $9,300, Jun $9,600

### A sideways ranking

`direction=horizontal` puts the places down the side, one row each, which is how a ranking reads and how long names fit. The numbers sit beside the bars, the threshold stands above the plot, and the series is named by the title of the axis.

**Diagram source**

```diagram
xychart "Slowest endpoints" direction=horizontal desc="The 95th percentile latency of the six slowest endpoints of the API in the last week, against the 400 millisecond objective."

x "Endpoint" ["POST /orders" "GET /search" "GET /reports/{id}" "POST /exports" "GET /invoices" "PUT /profile"]
y "p95 latency" unit="ms" min=0

bar "p95" [820 640 510 430 350 180]

threshold "SLO 400 ms" 400
```

*Picture: Slowest endpoints: 1 series (bar) over 6 places on the Endpoint axis, on a scale of 0 ms to 1,000 ms. The 95th percentile latency of the six slowest endpoints of the API in the last week, against the 400 millisecond objective.*

**Text description**

**X axis, Endpoint: POST /orders, GET /search, GET /reports/{id}, POST /exports, GET /invoices, PUT /profile.**

**Y axis, p95 latency: 0 ms to 1,000 ms.**

**Drawn with the places down the side.**

**Series:**

- p95 (bar): POST /orders 820 ms, GET /search 640 ms, GET /reports/{id} 510 ms, POST /exports 430 ms, GET /invoices 350 ms, PUT /profile 180 ms

**Thresholds:**

- SLO 400 ms: 400 ms
- Crossed above SLO 400 ms: p95 in POST /orders, GET /search, GET /reports/{id}, and POST /exports

### Numbers on the bars

A number is written above each bar, in the ink color, when there is room for it. When two numbers would touch, none is written, so a chart never has some numbers and not others for want of room. A number that a line or a threshold would run through is left out alone. `values=off` leaves them all out. The text description carries every value either way.

### Labels on a crowded axis

A place name wraps to two lines at a space. When even the longest word is wider than the room a place has, only every second, third, or later place is named, and lint warns. The other places are still drawn, and the text description names every one. Chart fewer places, or draw the chart sideways, to name them all.

## Limits and warnings

### Honest charts

A chart can mislead by what it leaves out. Plotwork warns instead of drawing a chart that does. A warning never stops the drawing.

| Warning | When it fires | What to do |
|---|---|---|
| `W200`, a baseline above zero | `min=` is above zero and the chart has a bar or an area, so the length of a bar no longer shows its value. | Start the axis at zero with `min=0`, or draw lines. |
| `W200`, more than 6 series | The colors stop being easy to tell apart. | Chart the few that matter, or draw one chart for each group. |
| `W200`, negative bars | A bar series has a value below zero. | Draw a line for a value that can fall below zero. |
| `W200`, a threshold outside the axis | `min=` or `max=` leaves a threshold out, so its line is not drawn. | Widen the bounds, or leave them out so the axis grows. |
| `W200`, nothing to stack | `stack=on` with fewer than two bar series. | Add a bar series, or remove `stack=on`. |
| `W200`, crowded labels | The names of the places had to be thinned. | Chart fewer places, shorten the names, or draw the chart sideways. |
| `W203` | A name, a title, or a label was cut with an ellipsis to fit its pill. | Shorten it. The full text stays in the description. |
| `W202` | The picture is wider than a Markdown column and its text would shrink. | Chart fewer places, or draw the chart sideways. |

### Limits

| Limit | Value | What happens past it |
|---|---|---|
| Places on the x axis | 60 | `E140` before anything is drawn, with the advice to group them. |
| Series | 8 | `E140`. Lint warns from 7. |
| Thresholds, notes | 4, 6 | `E140`. |
| A number | 1,000,000,000,000 | `E100`, at the number. |
| A unit | 12 characters | `E136`, at the value. |
| A pill | 240px | The name is cut with an ellipsis, with `W203`. |

### Errors

Every mistake comes back as a result with a code, a line, a column, and a hint. All the codes are the shared ones.

| Code | When it fires | Fix |
|---|---|---|
| `E100` | A list with no brackets or no closing bracket, a value that is text, has a percent sign, or is too large, a series whose count does not match the places, a chart with no x axis or no series, or a note with no place or on a sideways chart. | Read the hint, which gives the line to write. |
| `E101` | A name that is never closed. | Close it with a double quote. |
| `E110` | An unknown statement. | Use `x`, `y`, `bar`, `line`, `area`, `threshold`, `note`, or `desc`. |
| `E121`, `E122` | An unknown tone or icon. | The hint names the nearest. |
| `E130` | A series name used twice in any case, or a second `x` or `y`. | Rename the series. A chart has one of each axis, because two scales on one plot invent a link between the series. |
| `E135`, `E136` | An unknown option, or a bad `direction=`, `stack=`, `values=`, `breach=`, unit, bound, range, or note place. | The hint lists the choices. |
| `E140` | More places, series, thresholds, or notes than the limits above. | Group, split, or leave some out. |

## From Mermaid

Mermaid `xychart-beta` and `xychart` convert to an XY chart. Ask your agent to convert it, or use the `convert_mermaid` tool. The [migration guide](https://docs.plotwork.dev/migrating-from-mermaid.md) covers the converter in full.

```mermaid-source
xychart-beta
    title "Sales Revenue"
    x-axis [jan, feb, mar, apr, may, jun]
    y-axis "Revenue (in $)" 4000 --> 11000
    bar [5000, 6000, 7500, 8200, 9500, 10500]
    line [5000, 6000, 7500, 8200, 9500, 10500]
```

Mermaid does not name its series, so the converter names them `Bars` and `Line` and says so (`M204`). Rename them so the names beside the lines say what each one is. The axis starts at 4,000 because Mermaid said so, and a bar chart that does not start at zero draws a warning:

```plotwork
xychart "Sales Revenue"

x ["jan" "feb" "mar" "apr" "may" "jun"]
y "Revenue (in $)" min=4000 max=11000
bar "Bars" [5000 6000 7500 8200 9500 10500]
line "Line" [5000 6000 7500 8200 9500 10500]
```

| Mermaid | Plotwork | Report |
|---|---|---|
| `xychart-beta` or `xychart`, in any case | `xychart` | none |
| `horizontal` on the header | `direction=horizontal` | none |
| `title Text`, `title "Text"`, front matter `title:`, `accTitle:` | The title | none |
| `accDescr:` | `desc=` | none |
| `x-axis [a, b, c]`, with quotes or without | `x ["a" "b" "c"]` | none |
| `x-axis "Title" 0 --> 1000` | `x "Title" 0 to 1000` | none |
| No `x-axis` | The places counted from 1 | none |
| `y-axis "Title" 0 --> 100` | `y "Title" min=0 max=100` | none |
| `y-axis "Title"`, or no `y-axis` | The title only, or none. Plotwork chooses round ticks from zero. | none |
| `bar [...]`, `line [...]` | `bar "Bars" [...]`, `line "Line" [...]`, in the order written | `M204` when there are two or more and Mermaid named none |
| `bar "Name" [...]` | `bar "Name" [...]` | none |
| A second `x-axis` or `y-axis` | The first is kept. | `M209` |
| A series whose count does not match the places | Nothing is converted, or the series is skipped when the conversion is lenient. | `M110`, or `M218` |
| A value with a unit, a percent sign, an exponent, or quotes, a plot that is not a `bar` or a `line`, or a line that is not Mermaid | Nothing is converted, or the line is skipped when lenient. | `M110`, or `M218` |
| `%%{init}%%` and front matter `config:`, such as `width`, `height`, and `showDataLabel` | Dropped. | `M200` |
| `%%` comments | Dropped. | none |
| A chart with no title | No title is written. | `M214` |
| More than 60 places or 8 series | Nothing is converted. | `M102` |
| A chart with no series | Nothing is converted. | `M103` |

## See also

| Doc | Why |
|---|---|
| [Writing diagrams](https://docs.plotwork.dev/writing-diagrams.md) | The flow, sequence and schema languages, and the rules every kind shares |
| [Examples to copy](https://docs.plotwork.dev/examples.md) | Finished diagrams with the prompt that gets an agent to write each one |
| [Migrating from Mermaid](https://docs.plotwork.dev/migrating-from-mermaid.md) | Convert existing Mermaid diagrams, and what is dropped |
| [Pie charts](https://docs.plotwork.dev/pie-charts.md) | One total split into parts |
| [Quadrant charts](https://docs.plotwork.dev/quadrant-charts.md) | Items placed on two scores |
| [Gantt charts](https://docs.plotwork.dev/gantt-charts.md) | A plan drawn as tasks along a calendar |
| [Language reference](https://docs.plotwork.dev/reference/language.md) | Every kind, node type, option and diagnostic |
| [Diagram kinds](https://docs.plotwork.dev/reference/language/kinds.md) | The kinds Plotwork draws and the header of each |
