> ## Documentation Index
> Fetch the complete documentation index at: https://docs.graphy.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Heatmap

export const GraphEmbed = ({src, story, aspectRatio, borderRadius = '14px', args}) => {
  const STORYBOOK_BASE_URL = typeof window !== 'undefined' && window.location.hostname === 'localhost' ? 'http://localhost:6006' : 'https://storybook-sdk.vercel.app';
  function buildArgsString(args) {
    if (!args) return '';
    const encodeValue = value => {
      if (value === null) return '!null';
      if (value === undefined) return '!undefined';
      if (typeof value === 'boolean') return `!${value}`;
      if (typeof value === 'number') return String(value);
      return encodeURIComponent(String(value));
    };
    const properties = Object.entries(args).map(([key, value]) => `${key}:${encodeValue(value)}`).join(';');
    return `&args=${properties}`;
  }
  const resolvedSrc = story ? `${STORYBOOK_BASE_URL}/iframe.html?id=${story}&viewMode=story&embed=1&globals=mode:readonly${buildArgsString(args)}` : src;
  const resolvedAspectRatio = aspectRatio ?? (story ? '16 / 9' : '10 / 6');
  return <iframe src={resolvedSrc} loading="lazy" allowfullscreen="true" style={{
    width: '100%',
    aspectRatio: resolvedAspectRatio,
    border: 'none',
    borderRadius,
    colorScheme: 'light'
  }} />;
};

`geom.tile()` draws a rectangle that fills its cell on **both** axes. Where a bar encodes its value as a length, a tile encodes it as **colour**: the grid comes from a category on `x` and a category on `y`, and the value rides on the `color` aesthetic through a ramp. That makes it the tool for a matrix you want to read at a glance — cohort retention, revenue by product and region, activity by day and hour.

## Basic example

Three columns: one per axis, one for the value. Both position scales are bands, and the value's scale is a continuous colour ramp:

<GraphEmbed story="chart-types-tile-graph--cohort-retention" />

```tsx theme={null}
import { useMemo } from 'react';
import {
  GraphProvider,
  GraphRenderer,
  createSpec,
  pipe,
  geom,
  scale,
} from '@graphysdk/react';

const data = {
  columns: [
    { key: 'cohort', label: 'Cohort' },
    { key: 'week', label: 'Week' },
    { key: 'retention', label: 'Retention' },
  ],
  rows: [
    { cohort: 'Cohort 1', week: 'Week 1', retention: '86%' },
    { cohort: 'Cohort 1', week: 'Week 2', retention: '70%' },
    { cohort: 'Cohort 1', week: 'Week 3', retention: '66%' },
    { cohort: 'Cohort 2', week: 'Week 1', retention: '82%' },
    { cohort: 'Cohort 2', week: 'Week 2', retention: '71%' },
    { cohort: 'Cohort 2', week: 'Week 3', retention: '65%' },
    // ...one row per cell
  ],
};

export function CohortRetention() {
  const spec = useMemo(
    () =>
      pipe(
        createSpec({ x: 'cohort', y: 'week', color: 'retention' }),
        geom.tile(),
        scale.x(),
        scale.y(),
        scale.color.continuous({ scheme: 'viridis' })
      ),
    []
  );

  return (
    <GraphProvider input={spec} data={data}>
      <GraphRenderer />
    </GraphProvider>
  );
}
```

`geom.tile()` takes no `params`. A tile's geometry is entirely decided by the two bands it sits in, so there is no width or radius knob to turn — see [What the geom decides](#what-the-geom-decides) for the defaults it brings instead.

## Data shape

A heatmap wants **long** format: one row per cell, carrying both coordinates and the value. Matrix data usually arrives wide instead — one column per column of the grid — so pipe [`transform.reshape`](/sdk-next/advanced/transforms) in front of the mapping and map the columns it produces:

<GraphEmbed story="chart-types-tile-graph--wide-matrix" />

```tsx theme={null}
const data = {
  columns: [{ key: 'product' }, { key: 'North' }, { key: 'South' }, { key: 'East' }, { key: 'West' }],
  rows: [
    { product: 'Coffee', North: 18, South: 12, East: 15, West: 9 },
    { product: 'Tea', North: 7, South: 11, East: 6, West: 14 },
    { product: 'Pastry', North: 22, South: 19, East: 12, West: 10 },
    { product: 'Sandwich', North: 14, South: 8, East: 17, West: 13 },
  ],
};

const spec = pipe(
  createSpec(),
  transform.reshape({
    keep: ['product'],
    reshape: ['North', 'South', 'East', 'West'],
    keyName: 'region',
    valueName: 'revenue',
  }),
  mapping({ x: 'region', y: 'product', color: 'revenue' }),
  geom.tile(),
  scale.x(),
  scale.y(),
  scale.color.continuous()
);
```

## Color is the encoding

`color` is **required** on a tile layer, and the scale behind it is what makes the grid readable. On a tile the colour is the value, so with no `color` scale in the spec the engine infers one from the column: a numeric column gets the brand sequential ramp, and only a categorical one falls back to the ordinal palette — the waffle case, where the cells name a category rather than measuring one. Declare the scale to pick the ramp yourself:

```tsx theme={null}
scale.color.continuous(); // the brand sequential ramp
scale.color.continuous({ scheme: 'viridis' }); // a named colormap
scale.color.continuous({ range: ['#FFFFFF', '#0B5FFF'] }); // an explicit ramp
```

For data that crosses zero, reach for a **diverging** scheme and pin its neutral stop with `domainMid`. Without the pin, the neutral colour drifts to the data's midpoint — which is rarely zero — and the chart reads as though the break-even point moved:

<GraphEmbed story="chart-types-tile-graph--diverging-returns" />

```tsx theme={null}
const spec = pipe(
  createSpec({ x: 'month', y: 'asset', color: 'return' }),
  geom.tile(),
  scale.x(),
  scale.y(),
  scale.color.continuous({ scheme: 'RdBu', domainMid: 0 })
);
```

Setting `domainMid` also turns on `symmetric`, so a +8% and a −8% cell get equal colour intensity. [Continuous color](/sdk-next/concepts/scales#continuous-color) covers the full option set — schemes, explicit ramps and interpolation spaces.

## Gaps stay gaps

Only the rows you supply paint a cell. A grid with missing combinations leaves genuine holes rather than filling them with the ramp's low end, keeping "absent" and "zero" visually distinct — and a hole answers no hover, because the hit-test index drops the same cells the renderer skipped:

<GraphEmbed story="chart-types-tile-graph--sparse-grid" />

```tsx theme={null}
const data = {
  columns: [{ key: 'team' }, { key: 'quarter' }, { key: 'shipped' }],
  rows: [
    { team: 'Alpha', quarter: 'Q1', shipped: 5 },
    { team: 'Alpha', quarter: 'Q2', shipped: 8 },
    { team: 'Alpha', quarter: 'Q4', shipped: 11 }, // no Q3 — that cell stays empty
    { team: 'Beta', quarter: 'Q2', shipped: 3 },
    { team: 'Beta', quarter: 'Q3', shipped: 6 },
  ],
};
```

To show every combination, emit a row for it — the axes' band domains come from the values present in the data, so a category that appears nowhere gets no band at all. Pass `domain` on the scale to force the full set of bands: `scale.x.discrete({ domain: ['Q1', 'Q2', 'Q3', 'Q4'] })`.

## Value labels

[Data labels](/sdk-next/config/data-labels) are **on by default** on a tile — a heatmap is usually read cell by cell, so the number belongs in the cell. Each label centres in its tile, and is dropped when the cell is too small to hold it. The ink flips between dark and light according to the fill it sits on, so labels stay legible at both ends of the ramp.

Turn them off for a chart meant to be read as a texture:

```tsx theme={null}
geom.tile({ dataLabels: { showDataLabels: false } });
```

## What the geom decides

A tile carries defaults that a bar or a line wouldn't want, so a heatmap looks right before you configure anything:

| Default                            | Why                                                                                                                                                 |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Both axes are **band** scales      | A cell is addressed by a category on each axis. Declaring a continuous scale on either raises an `UNSUPPORTED_SCALE_TYPE` warning and the band wins |
| Band padding is `0`                | Cells tile the plane; the small inset between painted tiles is a render-side separation, not a scale gap                                            |
| The `y` band reads **top-down**    | First category at the top, the matrix convention. `scale.y.discrete({ reverse: false })` flips it back                                              |
| Grid lines are hidden on both axes | The cells already partition the panel                                                                                                               |
| The legend is never suppressed     | The gradient colour bar is the only place the value scale is written down, even with a single entry                                                 |
| Data labels are on                 | See above                                                                                                                                           |
| Position is `identity`             | There is no value axis to stack or dodge along                                                                                                      |

## Hover, highlights and annotations

Hover hit-tests the **cell the cursor is inside** — the full band, including the inset around the painted tile, so the whole grid is live. The hovered cell gains an outline, and the tooltip pairs the cell's `x` category as its header with a single row for the value the colour encodes.

[Highlights](/sdk-next/emphasis/highlights) work as they do everywhere — a predicate over the post-transform columns, with matched cells staying vivid while the rest step back:

<GraphEmbed story="features-highlights--tile-value-threshold" />

```tsx theme={null}
pipe(
  createSpec({ x: 'quarter', y: 'region', color: 'sales' }),
  geom.tile(),
  scale.x(),
  scale.y(),
  scale.color.continuous(),
  highlight({ variable: 'sales', gte: 2400 })
);
```

[Annotations](/sdk-next/emphasis/annotations) pinned to an observation address a cell with **two** values. On most geoms, `anchorValue` plus the group is enough; on a grid, `anchorValue` names a whole column, so `crossValue` names the band that picks one cell out of it:

<GraphEmbed story="chart-types-tile-graph--annotated-cells" />

```tsx theme={null}
annotation.pinnedNumber({
  at: { anchorValue: 'Cohort 5', crossValue: 'Week 1' },
});
```

An observation anchor resolves to the **centre** of its cell, and a pinned number prints the value the ramp encodes — on a tile that is the `color` value, not a length. A sticker centres on the cell too, so turn the cell labels off where the two would sit on top of each other.

The kinds that take a [panel anchor](/sdk-next/emphasis/annotations#point-anchors) — text, arrows, shapes — float over the grid in fractions of the plot rect instead, free of any cell. Give text an opaque background: a ramp runs light to dark under it, so a transparent label is unreadable at one end or the other.

```tsx theme={null}
annotation.text({
  id: 'caption',
  content: caption,
  at: { anchorType: 'panel', x: 0.24, y: 0.2 },
  width: 0.42,
  backgroundColor: '#ffffff',
  backgroundColorStyle: 'opaque',
});
```

The two halves join through an [annotation anchor](/sdk-next/emphasis/annotations#point-anchors): the arrow above runs from the caption's box to a cell, so neither end is a hand-tuned fraction. The caption's box is measured in the browser, so the tail re-flows when the text wraps differently or the plot resizes.

```tsx theme={null}
annotation.arrow({
  start: { anchorType: 'annotation', ref: 'caption', align: 'bottom' },
  end: { anchorType: 'observation', anchorValue: 'Cohort 5', crossValue: 'Week 5' },
  endArrowheadStyle: 'line-arrow',
});
```

## Limits

* **Cartesian only.** `coord.flip()` and `coord.polar()` reject a tile layer with an `UNSUPPORTED_COORD` error. A heatmap has no orientation to flip: swap the two mappings instead.
* **`identity` position only.** `'stack'`, `'dodge'` and `'fill'` raise `UNSUPPORTED_POSITION` — none of them mean anything without a value axis.
* **No paint vocabulary of its own.** There is no `style.geom.tile` target: the fill comes from the colour scale, and the corner rounding and inset are fixed. `style.geom({ alpha })` still applies, as do the `hovered` and `dimmed` [states](/sdk-next/rendering/interactivity#restyling-hover-and-dimming).

## Related

* [Scales](/sdk-next/concepts/scales#continuous-color) — colour ramps, schemes and diverging midpoints
* [Transforms](/sdk-next/advanced/transforms) — reshaping wide matrix data to long
* [Data labels](/sdk-next/config/data-labels) — the in-cell value labels
* [Highlights](/sdk-next/emphasis/highlights) — emphasising the cells a predicate matches
* [Annotations](/sdk-next/emphasis/annotations) — pinning a callout to one cell
