# BulletChart

A measure, its target, and the bands that say whether it is any good.

- Group: Data
- Import: `import { BulletChart } from '@misoto22/folio/charts'`
- Page: https://ui.misoto22.com/components/bullet-chart/
- Related: bar-list, big-number

## When to reach for it

A status page of ten tracked numbers. Stephen Few designed it to replace the dashboard gauge, which spends a whole card saying one number badly.

## Anatomy

- **Figure caption** (required) — title on the shared chart figure, referenced by aria-labelledby rather than left to be inferred from the figcaption, and hidden from sight unless showTitle prints it with description beneath.
- **Measure line** (required) — The row above each track: the measure’s name with its optional detail at the start, the formatted value at the end, and the target after a slash where there is one. This line is the reading a screen reader gets, because the graphic under it is not in the accessibility tree at all.
- **Track** (required) — The 24px aria-hidden box holding the bands, the bar and the target rule, laid out with inline-axis offsets rather than in SVG user space — which is what makes the whole chart mirror correctly in a right-to-left document.
- **Bands** — The qualitative ground, built from ranges as ascending upper bounds: heaviest at the low end of the scale and lightening as it rises, so the solid bar stands out most where performance is best.
- **Measure bar** (required) — The value, solid --series-1 at a third of the track’s height, running from the start of the scale. The one mark here that is a measurement rather than a judgement, and the only one drawn at full weight.
- **Target rule** — target, as a 2px rule of --ink straight across the bar rather than a second bar beside it — so which number was achieved and which was asked for is a glance rather than a comparison.

## Best practices

### Do

- Keep every range bound inside the domain. Bounds at or outside the two ends have no boundary to draw and are dropped before the bands are built, so ranges of 60 and 80 on a domain of 0 to 50 draws one flat band — the row LOOKS evaluated and is not. They stay in the table’s range-bounds cell, which is the only place the mismatch is visible.
- Hold to five bands. The weights are spread evenly from the full --chart-fill down to three tenths of it across however many bands are passed, so a sixth and a seventh boundary only slice that same span thinner and the ground stops having edges a reader can read a threshold off.
- Leave hideDataTable off unless the page prints the measures itself. The whole graphic is aria-hidden — bands, bar and target alike — so the generated sr-only table of value, target and range bounds is the only account of the chart a screen reader ever reaches.

### Don’t

- A value past the end of the scale is clamped, not overflowed: 130 on a domain of 0 to 100 fills the track exactly as 100 does. A notch at the end of the track says it happened and the figure printed above says by how much — but the track itself cannot, so pin domain wide enough for the overshoot you expect rather than reading the row that blew through its target as one that merely finished.
- Do not put a range bound on the target. Both are placed by the same scale, so the rule lands exactly on a band edge and the one mark that says what was ASKED for disappears into the ground it was meant to be read against.

## Accessibility

- Plain HTML with logical properties — no rendering engine, server-renderable, and correct in a right-to-left document. Usable with recharts absent.
- The bands are a JUDGEMENT drawn in the same ink as the measurement, so the page has to say where they came from. Ranges that encode nothing but thirds make the chart look evaluated when it is not.
- It shows one instant and no change over time; target is the only comparison it carries. “How did we get here” wants a LineChart.
- Shared bands only mean something when the measures share a scale — a latency beside a conversion rate needs ranges and domain per measure.

## BulletChart

A measure, its target, and the bands that say whether the number is any good — one dense row per thing being tracked. Stephen Few designed this to replace the dashboard gauge, and the argument has held: a speedometer spends a whole card saying one number badly, where a bullet graph says the same number, its target, and the qualitative context around it in the height of a line of text. Ten of them stack into a status page a reader can scan in one pass. What it will not do is show change over time. A bullet graph is one instant, and `target` is the only comparison it carries; when the question is "how did we get here" it wants a `<LineChart>`, and when several measures have to be compared against each OTHER rather than each against its own target, a `<BarList>` ranks them and this does not. **The bands are the part to be careful with.** They are a judgement — someone decided that 80 is "good" — drawn in the same ink as the measurement, and a reader has no way to tell an agreed threshold from a number somebody typed. They also compress: a value near the top of the scale sits in the same band whether it cleared the boundary by a point or by thirty. The band answers "is this acceptable", never "by how much". No rendering engine, deliberately — this is the one chart in the set with nothing to compute. Each row is a single linear scale with no axis, no ticks and no shared plot area, so it is laid out as HTML: the bands and the bar are inline-axis offsets, which means the whole chart mirrors correctly in a right-to-left document, where an SVG drawn in user space would not.

### Props

- `title` (required) — `string`. What the chart shows, in a sentence a reader could act on. Required, and announced to a screen reader even when it is not printed.
- `showTitle` — `boolean`. Prints the title above the rows instead of hiding it from sight.
- `description` — `ReactNode`. A line under the title — the unit, the window, the caveat.
- `data` (required) — `BulletMeasure[]`. One entry per measure. Order is the reading order; it is not sorted.
- `ranges` — `number[]`. The bands every measure falls back to, as ascending upper bounds. Shared bands only mean something when the measures share a scale. Where they do not — a latency beside a conversion rate — put `ranges` and `domain` on each measure instead.
- `domain` — `[number, number]`. The scale every measure falls back to, as `[min, max]`.
- `formatValue` — `(value: number) => string` default `defaultTick`. Formats every number the chart prints.
- `showScale` — `boolean` default `false`. Prints the scale's two ends under each track.
- `className` — `string`. Merged onto the figure, last, so a call site can size or space it.
- `hideDataTable` — `boolean` default `false`. Drops the hidden table view. Only correct when the page prints the data itself.
- `empty` — `ChartEmptyProps`. What the chart shows when it has nothing to draw.

## Example — default

```tsx
import { BulletChart } from '@misoto22/folio/charts'

<BulletChart
  title="Quarterly targets"
  showTitle
  description="Bar is the measure, rule is the target"
  data={data}
  ranges={[50, 80]}
  domain={[0, 100]}
/>
```

## Example — per measure scales

```tsx
import { ToggleGroup, ToggleGroupItem } from '@misoto22/folio'
import { BulletChart, formatNumber } from '@misoto22/folio/charts'

<div className="flex w-full flex-col gap-4">
  <ToggleGroup
    type="single"
    value={showScale ? 'shown' : 'hidden'}
    onValueChange={(next) => next && setShowScale(next === 'shown')}
    aria-label="Scale labels"
  >
    <ToggleGroupItem value="hidden">no scale</ToggleGroupItem>
    <ToggleGroupItem value="shown">scale</ToggleGroupItem>
  </ToggleGroup>

  <BulletChart
    title="Platform health"
    data={data}
    showScale={showScale}
    formatValue={formatNumber({ style: 'plain' })}
  />
</div>
```

## Example — empty

```tsx
import { Button } from '@misoto22/folio'
import { BulletChart } from '@misoto22/folio/charts'

<BulletChart
  title="Quarterly targets"
  showTitle
  description="Bar is the measure, rule is the target"
  data={[]}
  empty={{
    title: 'No targets set for this quarter',
    description: 'Targets carry over when a quarter opens. This one has none yet.',
    action: <Button variant="secondary">Set targets</Button>,
  }}
/>
```
