# Graph

> Real Word charts — bar, line, area, pie, doughnut, scatter, radar or bubble — declared as data rather than drawn as a picture.

Source: https://docxcelerate.com/docs/nodes/graph/

Charts are declared, never drawn: `graphType` fixes the form, `data` carries the payload. What lands in the `.docx` is a DrawingML chart part with every value cached in it — the chart Word builds from Insert → Chart — so a reader can select it, restyle it and open its numbers. Nothing is rasterised, which is why charts cost the package no drawing library and no native module.

- **Helpers:** `Graph`
- **Node kind:** `graph`
- **Category:** Data
- **Resolves:** Both
- **Children:** None.

| Option | Type | What it does |
| --- | --- | --- |
| `id` | `string` | Stable address for the node. Engines target it and build artifacts diff on it, so treat a rename as a breaking change. Optional: a node without one takes an id from where it sits, which is what keeps branches and loops from forcing you to invent names. Two nodes claiming one id is an error rather than a race. |
| `graphType` | `"bar" \| "barHorizontal" \| "line" \| "area" \| "pie" \| "doughnut" \| "scatter" \| "radar" \| "bubble"` | The form of the chart. Defaults to `bar`. `radar` closes the category axis into a ring, for comparing shapes rather than amounts; `bubble` is a scatter whose points carry a third figure as their size, from `sizes` on the series. |
| `data` _required_ | `GraphData` | Static only. `{ categories?, series: { label?, values, color?, sizes? }[] }`. A `null` value is a gap rather than a zero. String values in it are run through the template renderer, so `{{derived.total}}` resolves inside the payload as it would in prose. |
| `stacked` | `boolean \| "percent"` | Whether the series stack. `true` stacks them to a total; `"percent"` stacks them to the same height, so what is read is each series' share of its category — the 100% stacked chart. Bars, lines and areas take it; a pie is already a share of a whole. |
| `caption` | `string` | Printed beneath the chart, as a paragraph of its own rather than text drawn into the frame. Optional on both modes. |
| `placeholder` | `string` | Dynamic only. What previews show in place of generated content. Also settable with `useSetPlaceholders`. Optional, but a document that reads badly without one cannot be reviewed. |
| `generalPrompt` _required_ | `string` | Dynamic only. What this node should say. |
| `infoPrompt` | `string` | Dynamic only. Context the model should have but should not restate. |
| `negativePrompt` | `string` | Dynamic only. What to avoid — claims, tones, or facts it must not invent. |
| `systemPrompt` | `string` | Dynamic only. Role and voice, applied ahead of the other prompts. |
| `examplePrompt` | `string` | Dynamic only. What a good answer looks like, written out as finished text. Shown last, because it is what the answer gets measured against. |
| `derivers` | `DeriverInvocation[]` | Values the engine computes before the node resolves, written to `derived.*` and readable from a template token. Built with `derive()`. These survive publishing and run per document — use them for anything computed from request data. `useDeriver` runs one during the build instead. |

## Declared, not drawn

A graph node holds numbers and a form, never an image:

```tsx
export const VisitsByMonth: Graph = () => {
  const [state] = useState((data: MemberData) => ({
    months: data.visitsByMonth.map((entry) => entry.month),
    visits: data.visitsByMonth.map((entry) => entry.visits),
  }));

  return (
    <Graph
      id="visits-by-month"
      title="Visits by month"
      graphType="bar"
      valueAxisTitle="Visits"
      data={{
        categories: state.months,
        series: [{ label: "Visits", values: state.visits }],
      }}
      caption="Your visits, last six months"
    />
  );
};
```

What lands in the `.docx` is a **real Word chart** — a DrawingML chart part with
every value cached in it, plus the workbook that "Edit Data" opens. The reader
can select it, restyle it, change its type and open its numbers, and Word draws
it as vectors at whatever size the page and the printer need.

That is the whole reason the node carries figures instead of a picture. A chart
flattened to pixels at build time is a chart nobody downstream can do anything
with: it cannot be re-themed, it prints at whatever resolution the build
guessed, and it is invisible to anything reading the document. Nothing here is
rasterised, which is also why charts cost the package no extra dependency and no
native module.

## The shape of the data

```ts
interface GraphData {
  categories?: string[];
  series: Array<{
    label?: string;
    values: (number | null)[];
    color?: string;
    /** A bubble's third figure, and read by no other form. */
    sizes?: (number | null)[];
  }>;
}
```

A `null` is a **gap, not a zero**. A month nobody has read yet is not a month
that read nothing, and drawing it as zero puts a cliff in the line that never
happened. Word is told to leave the point out.

A series shorter than the categories is padded with gaps; one given no
categories is counted by position. Strings inside the payload are run through
the template renderer the same as paragraph text, so a label containing
`{{derived.total}}` resolves.

`scatter` and `bubble` read the categories as their **x values**, so those are
numbers written as text; anything that will not parse counts as its position.

## Colours belong to the theme

Series take the theme's `palette.series` in order — a fixed, validated,
colourblind-safe set — so a document re-themes without its charts going out of
step with it. Gridlines take `palette.rule` and the axis and key text
`palette.muted`, so a chart reads as part of the document rather than as
something pasted into it.

Name a `color` on a series only when the colour is what the series *means* — a
red line for the limit it must stay under — and never to pick a nicer blue.

## Forms

| `graphType` | What it is for |
| --- | --- |
| `bar` | Comparing categories. The default, and usually the right answer. |
| `barHorizontal` | The same, for category names too long to sit under a column. |
| `line` | Change over time. |
| `area` | Change over time where the quantity under the line is the point. |
| `pie` | A share of a whole, and only with few enough slices to tell apart. |
| `doughnut` | The same, with the middle taken out. |
| `scatter` | Two measures against each other, to show whether they move together. |
| `bubble` | The same with a third figure as the point's size — `sizes` on the series. |
| `radar` | Several measures at once, read as a shape: strong where, not how much. |

Reach for `bar` first. Past about six slices a pie is harder to read than the
bar chart of the same numbers, and a radar answers a different question from
all of them — it compares profiles, and a reader cannot compare the areas of
two rings, so do not ask them to.

Two measures of different scales are two charts. A second axis makes the
crossing point look like a finding when it is an artefact of the scales you
chose.

### Stacking

Stacking is a property rather than a form of its own, so it applies to bars,
lines and areas alike and cannot be asked of a pie:

```tsx
<Graph graphType="bar" stacked data={…} />              // to a total
<Graph graphType="bar" stacked="percent" data={…} />    // to a share
```

`stacked` makes the top of each stack the total. `stacked="percent"` draws
every column to the same height, so what is read is each series' share of its
column and the totals are deliberately thrown away — the chart Word calls 100%
stacked. Pass the raw figures either way: Word works the shares out itself and
labels the axis 0% to 100%, so a payload that pre-divided would hand it shares
of shares.

## Deriving in state

Running totals, percentages and rebasing belong in the state initializer rather
than in the data you pass in. The chart and the text beside it are then worked
out from one source, so they cannot disagree.

## In the preview

`docx-preview` has no reading of a chart part at all, so it leaves an empty
frame at exactly the size the file gave the chart — which is what keeps the
preview paginating like Word. A scaffolded workspace fills that frame with
[ECharts](https://echarts.apache.org), drawing from the same packed bytes; see
`preview/charts.ts` in your workspace.

The frame is exact. The plot inside it is another renderer's drawing of the same
data, not a screenshot of Word — open the `.docx` when the plot itself has to be
right.

## What draws it

Two renderers, and it is worth knowing which is which:

- **In the file** — [OOXML DrawingML](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oi29500/),
  the chart format Word's own Insert → Chart writes: a `c:chartSpace` part with
  every value cached in it, plus a small embedded `.xlsx` for "Edit Data".
  Docxcelerate writes that part directly, so **Word does the drawing** — no
  charting library, no canvas, no native module.
- **In the preview** — [ECharts](https://echarts.apache.org), reading the same
  packed bytes back. It draws the plot on screen; the frame around it still
  comes from the file.

Which is why the two can differ by a pixel and the page cannot: what the layout
depends on is the frame, and the frame is Word's.

## Variants

### Bar

Discrete values across a handful of buckets.

Source: `src/nodes/graph/bar.node.tsx`

```tsx
import { Graph, useState } from "docxcelerate/template";
import type { SampleData } from "../sample-data.ts";

/**
 * A chart is declared, not drawn: `graphType` fixes the form, `data` carries
 * the payload. One node then serves the browser preview and the packed DOCX.
 */
export const VisitsByMonth: Graph = () => {
  const [state] = useState((data: SampleData) => ({
    centreName: data.centreName,
    months: data.visitsByMonth.map((entry) => entry.month),
    visits: data.visitsByMonth.map((entry) => entry.visits),
  }));

  return (
    <Graph
      id="visits-by-month"
      title={`Visits to ${state.centreName}`}
      graphType="bar"
      valueAxisTitle="Visits"
      data={{
        categories: state.months,
        series: [{ label: "Visits", values: state.visits }],
      }}
      caption={`Your visits to ${state.centreName}, last six months`}
    />
  );
};
```

**What it resolves to**

```json
{
  "id": "visits-by-month",
  "kind": "graph",
  "mode": "static",
  "graphType": "bar",
  "title": "Visits to Riverside Leisure Centre",
  "valueAxisTitle": "Visits",
  "data": {
    "categories": [
      "Apr",
      "May",
      "Jun",
      "Jul",
      "Aug",
      "Sep"
    ],
    "series": [
      {
        "label": "Visits",
        "values": [
          11,
          14,
          9,
          16,
          18,
          12
        ]
      }
    ]
  },
  "caption": "Your visits to Riverside Leisure Centre, last six months"
}
```

### Line

The same data as a running total — derived in `data`, not upstream.

Source: `src/nodes/graph/line.node.tsx`

```tsx
import { Graph, useState } from "docxcelerate/template";
import type { SampleData } from "../sample-data.ts";

/**
 * Only `graphType` changes between the three static forms. Deriving the
 * running total inside the state initializer keeps the chart and the prose
 * from disagreeing, and keeps the work out of the returned element.
 */
export const CumulativeVisits: Graph = () => {
  const [state] = useState((data: SampleData) => {
    let running = 0;

    return {
      months: data.visitsByMonth.map((entry) => entry.month),
      toDate: data.visitsByMonth.map((entry) => (running += entry.visits)),
    };
  });

  return (
    <Graph
      id="cumulative-visits"
      title="Visits to date"
      graphType="line"
      data={{
        categories: state.months,
        series: [{ label: "Visits to date", values: state.toDate }],
      }}
      caption="Visits accumulated across the membership year"
    />
  );
};
```

**What it resolves to**

```json
{
  "id": "cumulative-visits",
  "kind": "graph",
  "mode": "static",
  "graphType": "line",
  "title": "Visits to date",
  "data": {
    "categories": [
      "Apr",
      "May",
      "Jun",
      "Jul",
      "Aug",
      "Sep"
    ],
    "series": [
      {
        "label": "Visits to date",
        "values": [
          11,
          25,
          34,
          50,
          68,
          80
        ]
      }
    ]
  },
  "caption": "Visits accumulated across the membership year"
}
```

### Pie

Shares of a whole.

Source: `src/nodes/graph/pie.node.tsx`

```tsx
import { Graph, useState } from "docxcelerate/template";
import type { SampleData } from "../sample-data.ts";

/**
 * Shares of a whole. `caption` is optional, but a chart in a letter is read
 * once and not returned to.
 */
export const ClassMix: Graph = () => {
  const [state] = useState((data: SampleData) => ({
    activities: data.classMix.map((entry) => entry.label),
    shares: data.classMix.map((entry) => entry.share),
  }));

  return (
    <Graph
      id="class-mix"
      title="Share of visits"
      graphType="pie"
      dataLabels
      data={{
        categories: state.activities,
        series: [{ label: "Share of visits", values: state.shares }],
      }}
      caption="How you used the centre, by activity"
    />
  );
};
```

**What it resolves to**

```json
{
  "id": "class-mix",
  "kind": "graph",
  "mode": "static",
  "graphType": "pie",
  "title": "Share of visits",
  "dataLabels": true,
  "data": {
    "categories": [
      "Swim",
      "Strength",
      "Classes"
    ],
    "series": [
      {
        "label": "Share of visits",
        "values": [
          42,
          33,
          25
        ]
      }
    ]
  },
  "caption": "How you used the centre, by activity"
}
```

### Stacked to a share

`stacked="percent"` — the mix within each column, not the size of it.

Source: `src/nodes/graph/stacked.node.tsx`

```tsx
import { Graph, useState } from "docxcelerate/template";
import type { SampleData } from "../sample-data.ts";

/**
 * `stacked="percent"` is the 100% stacked chart, and it is a different reading
 * from `stacked`: every column is drawn to the same height, so what is
 * compared is the mix within each month rather than the size of the months.
 *
 * The raw counts are what the file carries. Word works the shares out itself
 * and labels the axis 0% to 100%, so nothing here divides — pre-dividing would
 * hand Word shares of shares.
 */
export const VisitMix: Graph = () => {
  const [state] = useState((data: SampleData) => ({
    months: data.visitMix.map((entry) => entry.month),
    swim: data.visitMix.map((entry) => entry.swim),
    strength: data.visitMix.map((entry) => entry.strength),
    classes: data.visitMix.map((entry) => entry.classes),
  }));

  return (
    <Graph
      id="visit-mix"
      title="What you came for, month by month"
      graphType="bar"
      stacked="percent"
      data={{
        categories: state.months,
        series: [
          { label: "Swim", values: state.swim },
          { label: "Strength", values: state.strength },
          { label: "Classes", values: state.classes },
        ],
      }}
      caption="Share of each month's visits, by activity"
    />
  );
};
```

**What it resolves to**

```json
{
  "id": "visit-mix",
  "kind": "graph",
  "mode": "static",
  "graphType": "bar",
  "title": "What you came for, month by month",
  "stacked": "percent",
  "data": {
    "categories": [
      "Apr",
      "May",
      "Jun",
      "Jul",
      "Aug",
      "Sep"
    ],
    "series": [
      {
        "label": "Swim",
        "values": [
          5,
          6,
          4,
          8,
          9,
          5
        ]
      },
      {
        "label": "Strength",
        "values": [
          4,
          5,
          3,
          5,
          6,
          4
        ]
      },
      {
        "label": "Classes",
        "values": [
          2,
          3,
          2,
          3,
          3,
          3
        ]
      }
    ]
  },
  "caption": "Share of each month's visits, by activity"
}
```

### Radar

Two shapes held against one another, on one shared scale.

Source: `src/nodes/graph/radar.node.tsx`

```tsx
import { Graph, useState } from "docxcelerate/template";
import type { SampleData } from "../sample-data.ts";

/**
 * Two shapes held against one another. A radar answers "strong where", not
 * "how much" — a reader cannot compare the areas of two rings, and is not
 * meant to. The axis is scored out of 100 so the spokes share a scale;
 * plotting raw counts of different things on one ring compares nothing.
 */
export const FacilityMix: Graph = () => {
  const [state] = useState((data: SampleData) => ({
    facilities: data.facilityUse.map((entry) => entry.facility),
    you: data.facilityUse.map((entry) => entry.you),
    average: data.facilityUse.map((entry) => entry.average),
  }));

  return (
    <Graph
      id="facility-mix"
      title="What you used, against the average"
      graphType="radar"
      data={{
        categories: state.facilities,
        series: [
          { label: "You", values: state.you },
          { label: "Centre average", values: state.average },
        ],
      }}
      caption="Use of each facility, scored out of 100"
    />
  );
};
```

**What it resolves to**

```json
{
  "id": "facility-mix",
  "kind": "graph",
  "mode": "static",
  "graphType": "radar",
  "title": "What you used, against the average",
  "data": {
    "categories": [
      "Pool",
      "Gym",
      "Classes",
      "Sauna",
      "Courts"
    ],
    "series": [
      {
        "label": "You",
        "values": [
          82,
          61,
          45,
          30,
          12
        ]
      },
      {
        "label": "Centre average",
        "values": [
          54,
          70,
          63,
          22,
          38
        ]
      }
    ]
  },
  "caption": "Use of each facility, scored out of 100"
}
```

### Dynamic

Figures the endpoint derives; the form still fixed locally.

Source: `src/nodes/graph/dynamic.node.tsx`

```tsx
import { Graph, useSetPlaceholders, useSetPrompts, useState } from "docxcelerate/template";
import type { SampleData } from "../sample-data.ts";

/**
 * For figures that need deriving rather than reading. `graphType` still fixes
 * the form locally, so the layout is known before the numbers are.
 */
export const PeakTimes: Graph = () => {
  const [state] = useState((data: SampleData) => ({
    name: data.memberName,
    ref: data.membershipRef,
    plan: data.plan,
  }));

  useSetPrompts({
    generalPrompt: `Plot when ${state.name} (${state.ref}) visits, bucketed by ` +
      `hour of the day across the week.`,
    infoPrompt: `Their plan is ${state.plan}, which allows entry at any hour.`,
  });

  useSetPlaceholders("Your busiest hours, Monday to Sunday");

  return <Graph id="peak-times" graphType="bar" />;
};
```

**What it resolves to**

```json
{
  "id": "peak-times",
  "kind": "graph",
  "mode": "dynamic",
  "graphType": "bar",
  "placeholder": "Your busiest hours, Monday to Sunday"
}
```

**What the endpoint is asked**

- `general` — Plot when Adaeze Nkemelu (RIV-88214) visits, bucketed by hour of the day across the week.
- `info` — Their plan is Peak Anytime, which allows entry at any hour.

## Notes

- `graphType` defaults to `bar` in both modes.
- `title` is drawn *on* the chart and travels with it if the reader copies it
  elsewhere; `caption` is a paragraph printed underneath.
- `legend` defaults to a key under the plot for more than one series and none
  for one — a key naming the only thing on a chart repeats its title. A pie
  always keeps one, because its key names the slices.
- `numberFormat` is an OOXML format code — `"#,##0"`, `"0.0%"`, `"£#,##0"` —
  handed to the value axis and the data labels verbatim.
- `barHorizontal` reads top-down in the order the categories were written,
  rather than bottom-up the way Word's own bar charts do.
- On a dynamic graph the form is still fixed locally. The endpoint supplies the
  figures, and may override `graphType` and `caption` in its response.
