# Numeric axes

For data from 101 to 119, an axis spanning 100–120 exposes the variation
without forcing a zero baseline. Viz chooses numeric steps from multiples of
1, 2, and 5 across powers of ten.

Switch the example to a constant signal: its automatic domain expands around
the recorded value.

```tsx file="NumericChartSpec.tsx"

// Keep this fixture identical across the old and new chart implementations.
const x = Array.from({ length: 121 }, (_, index) => index / 2);
const y = x.map((time) => 12.35 + 2.87 * Math.sin((time * Math.PI) / 30));
const signal = {
  id: "sensor",
  label: "Sensor reading",
  color: EPISODE_LINE_CHART_PALETTE.blue,
  data: { x, y },
};
const constant = { ...signal, data: { x, y: x.map(() => 100) } };

export function NumericChartSpec() {
  const [constantRecording, setConstantRecording] = useState(false);
  const [time, setTime] = useState<number | null>(null);
  const [playhead, setPlayhead] = useState<number | null>(null);
  return (
    <div className="space-y-3" data-testid="numeric-chart-spec">
      <div className="flex flex-wrap items-center justify-between gap-3 text-xs">
        <label className="flex items-center gap-2">
          <input
            type="checkbox"
            checked={constantRecording}
            onChange={(event) => setConstantRecording(event.target.checked)}
          />
          Constant recording (100 V)
        </label>
        <output>
          {playhead === null
            ? "Click chart to seek"
            : `Playhead: ${playhead.toFixed(2)} s`}
        </output>
      </div>
      <div className="h-[260px]">
        <EpisodeLineChart
          title="Sensor voltage · automatic numeric axis"
          caption="Synthetic · 121 samples · 60 seconds"
          series={[constantRecording ? constant : signal]}
          duration={60}
          unit="V"
          time={time}
          onHover={setTime}
          onHoverEnd={() => setTime(null)}
          onSeek={setPlayhead}
        />
      </div>
      <p className="text-xs opacity-70">
        Identical recorded samples determine the automatic domain. Hover to
        inspect the original values; click to seek.
      </p>
    </div>
  );
}
```

## Tick and domain helpers

Build a custom axis by choosing its domain, finding ticks, then formatting them:

```ts

const domain = niceNumericDomain(101, 119); // [100, 120]
const ticks = numericTicks(...domain); // [100, 105, 110, 115, 120]
const labels = ticks.map((value) => formatNumericTick(value, 5));
```

Adjust the bounds and count below. Tick count is a target; readable spacing
can produce a different number of ticks.

```tsx file="NumericAxisSpec.tsx"

const EXAMPLES = [
  { label: "Positive signal", min: 13, max: 87 },
  { label: "Fractional signal", min: -0.31, max: 0.81 },
  { label: "Small variation around 110", min: 101, max: 119 },
  { label: "Constant recording", min: 100, max: 100 },
  { label: "Microscopic values", min: 1.3e-8, max: 8.7e-8 },
];

export function NumericAxisSpec() {
  const [example, setExample] = useState(0);
  const [automatic, setAutomatic] = useState(true);
  const { min, max } = EXAMPLES[example];
  const [low, high] = automatic ? niceNumericDomain(min, max) : [min, max];
  const ticks = numericTicks(low, high);
  const step = ticks.length > 1 ? ticks[1] - ticks[0] : 1;
  const x = (value: number) =>
    low === high ? 380 : 45 + ((value - low) / (high - low)) * 670;
  return (
    <div className="space-y-4 text-sm" data-testid="numeric-axis-spec">
      <div className="flex flex-wrap items-center gap-4">
        <label className="flex items-center gap-2">
          Recording
          <select
            className="rounded border border-current/20 bg-transparent px-2 py-1"
            value={example}
            onChange={(event) => setExample(Number(event.target.value))}
          >
            {EXAMPLES.map((item, index) => (
              <option key={item.label} value={index}>
                {item.label}
              </option>
            ))}
          </select>
        </label>
        <label className="flex items-center gap-2">
          <input
            type="checkbox"
            checked={automatic}
            onChange={(event) => setAutomatic(event.target.checked)}
          />
          Expand automatic domain
        </label>
      </div>
      <svg
        viewBox="0 0 760 135"
        role="img"
        aria-label={`${EXAMPLES[example].label}: tick labels ${ticks.map((value) => formatNumericTick(value, step)).join(", ")}`}
        className="w-full"
      >
        <line
          x1="45"
          y1="55"
          x2="715"
          y2="55"
          stroke="currentColor"
          opacity="0.3"
        />
        <line
          x1={x(min)}
          y1="42"
          x2={x(max)}
          y2="42"
          stroke="currentColor"
          strokeWidth="5"
          opacity="0.45"
        />
        {min === max && (
          <circle cx={x(min)} cy="42" r="4" fill="currentColor" />
        )}
        {ticks.map((value) => (
          <g key={value}>
            <line
              x1={x(value)}
              y1="55"
              x2={x(value)}
              y2="63"
              stroke="currentColor"
            />
            <text
              x={x(value)}
              y="85"
              textAnchor="middle"
              fill="currentColor"
              fontSize="13"
            >
              {formatNumericTick(value, step)}
            </text>
          </g>
        ))}
        <text
          x="380"
          y="120"
          textAnchor="middle"
          fill="currentColor"
          fontSize="12"
        >
          Thick mark: recorded range · ticks: plot coordinates
        </text>
      </svg>
      <dl className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-xs">
        <dt>Recorded range</dt>
        <dd>
          {min} → {max}
        </dd>
        <dt>Plot domain</dt>
        <dd>
          {low} → {high}
        </dd>
        <dt>Tick count</dt>
        <dd>{ticks.length} (target: 5)</dd>
      </dl>
    </div>
  );
}
```

| Helper                                   | Result                                                                                                        |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `numericTicks(min, max, count = 5)`      | Ascending finite ticks inside the supplied domain.                                                            |
| `niceNumericDomain(min, max, count = 5)` | A domain rounded outward to readable tick boundaries.                                                         |
| `formatNumericTick(value, step)`         | A label with enough precision to distinguish ticks, using scientific notation for very small or large values. |

[Line charts](/components/episode-line-chart.md) and
[parallel coordinates](/components/parallel-coordinates.md) use these helpers.
An explicit `yRange` or axis `domain` keeps your bounds unchanged.

<span id="domain-and-label-contracts" />

## Domains and labels

Position ticks by `(value - min) / (max - min)`, not by their array index.
Otherwise ticks inside an explicit domain can appear at the wrong positions.

`numericTicks` returns one value for a constant domain; `niceNumericDomain`
expands it for display, using `[-1, 1]` at zero. Invalid or reversed bounds
produce no ticks and a fallback domain of `[0, 1]`.

Numeric labels use enough precision to distinguish ticks. In line charts,
`formatValue` can supply units and default axis labels; `formatTick` overrides
axis text. Default tooltip values use two decimals. Time axes have separate
clock formatting.
