# Graph

> Echte Word-grafieken — staaf, lijn, vlak, taart, ring of spreiding — als data gedeclareerd in plaats van als plaatje getekend.

Source: https://docxcelerate.com/nl/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`
- **Nodesoort:** `graph`
- **Categorie:** Data
- **Wordt opgelost:** Both
- **Children:** None.

| Optie | Type | Wat het doet |
| --- | --- | --- |
| `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` _verplicht_ | `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` _verplicht_ | `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. |

## Gedeclareerd, niet getekend

Een graph-node bevat getallen en een vorm, nooit een afbeelding:

```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"
    />
  );
};
```

Wat in de `.docx` belandt is een **echte Word-grafiek** — een
DrawingML-chartdeel met alle waarden erin, plus de werkmap die „Gegevens
bewerken" opent. De lezer kan hem selecteren, opnieuw vormgeven, van type
veranderen en zijn cijfers openen, en Word tekent hem als vectoren op elk
formaat. Niets hiervan wordt gerasterd.

## De vorm van de data

`data` is getypeerd: categorieën en series, elke serie met een label, zijn
waarden en optioneel een kleur.

```ts
interface GraphData {
  categories?: string[];
  series: Array<{ label?: string; values: (number | null)[]; color?: string }>;
}
```

Een `null` is een gat, geen nul. Een maand die nog niet is afgelezen is geen
maand waarin niets is gemeten — Word laat het punt weg in plaats van de lijn te
laten inzakken.

Kleuren komen uit het thema (`palette.series`, een vaste reeks die op
kleurenblindheid is getoetst), zodat een document opnieuw kan worden vormgegeven
zonder dat de grafieken uit de pas gaan lopen.

Eén ding gebeurt er wel met de data: stringwaarden gaan door de templaterenderer
heen, net als proza. Een label met `{{derived.total}}` erin wordt opgelost.

## Afleiden in de state

Lopende totalen, percentages en herbasering horen in de `data`-functie thuis en
niet stroomopwaarts. De grafiek en de tekst ernaast worden dan uit één bron
berekend, zodat ze het niet oneens kunnen zijn.

## Varianten

### 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`}
    />
  );
};
```

**Waartoe het wordt opgelost**

```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"
    />
  );
};
```

**Waartoe het wordt opgelost**

```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"
    />
  );
};
```

**Waartoe het wordt opgelost**

```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"
    />
  );
};
```

**Waartoe het wordt opgelost**

```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"
    />
  );
};
```

**Waartoe het wordt opgelost**

```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" />;
};
```

**Waartoe het wordt opgelost**

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

**Wat er aan het endpoint wordt gevraagd**

- `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.

## Aantekeningen

- `graphType` staat op beide helpers standaard op `bar`.
- Bij een dynamische graph ligt de vorm nog steeds lokaal vast. Het endpoint
levert de cijfers en mag `graphType` en `caption` in zijn antwoord
overschrijven.
