# Series identification

Use `highlightOnHover` to find a signal among overlapping traces. The nearest
trace and its readout gain emphasis while the shared comparison time stays put.

## Seven signals, one shared time

Hover the seventh signal to bring its full label into the five-row tooltip.
Its missing-data gap clears the hover highlight. Use the signal buttons to
make the same choice by keyboard or touch.

```tsx file="SeriesIdentificationSpec.tsx"

const x = Array.from({ length: 121 }, (_, i) => i / 2);
const names = [
  "Wrist rotation",
  "Elbow effort",
  "Shoulder effort",
  "Left gripper force",
  "Tool vibration",
  "Wrist target effort",
  "Right gripper contact force after calibration",
];
const colors = Object.values(EPISODE_LINE_CHART_PALETTE);
const signals: EpisodeLineChartSeries[] = names.map((label, i) => ({
  id: `signal-${i + 1}`,
  label,
  color: colors[i % colors.length],
  data: {
    x,
    y: x.map((t) =>
      i === 6 && t >= 24 && t <= 28
        ? NaN
        : 9 + i * 8 + 3 * Math.sin(t / 6 + i * 0.5),
    ),
  },
  formatValue: (value) => `${value.toFixed(2)} N`,
}));

export function SeriesIdentificationSpec() {
  const [enabled, setEnabled] = useState(true);
  const [time, setTime] = useState<number | null>(null);
  const [activeId, setActiveId] = useState<string | null>(null);
  const [pinned, setPinned] = useState(false);
  const active = signals.find((signal) => signal.id === activeId);
  const reset = () => {
    setActiveId(null);
    setPinned(false);
    setTime(null);
  };
  return (
    <div
      data-testid="series-identification-demo"
      className="space-y-3 text-xs"
      onKeyDownCapture={(event) => {
        if (event.key === "Escape") {
          event.preventDefault();
          event.stopPropagation();
          reset();
        }
      }}
    >
      <div className="flex flex-wrap items-center justify-between gap-2">
        <label className="flex items-center gap-2">
          <input
            type="checkbox"
            checked={enabled}
            onChange={(event) => setEnabled(event.target.checked)}
          />
          Identify nearby series
        </label>
        <div className="flex items-center gap-2">
          <button
            type="button"
            aria-pressed={pinned}
            className="rounded px-2 py-1 hover:bg-black/[0.04] dark:hover:bg-white/[0.05]"
            onClick={() => {
              if (!pinned) setTime((value) => value ?? 30);
              setPinned((value) => !value);
            }}
          >
            {pinned ? "Unpin time" : "Pin current time"}
          </button>
          <button
            type="button"
            className="rounded px-2 py-1 hover:bg-black/[0.04] dark:hover:bg-white/[0.05]"
            onClick={reset}
          >
            Reset identification
          </button>
        </div>
      </div>
      <div className="h-[260px]">
        <EpisodeLineChart
          title="Seven force signals · identify the nearby trace"
          caption="Synthetic · recorded samples · same units"
          series={signals}
          duration={60}
          yRange={[0, 70]}
          time={time}
          highlightOnHover={enabled}
          highlightThresholdPx={8}
          activeSeriesId={enabled ? activeId : null}
          onActiveSeriesChange={(id) => {
            if (!pinned) setActiveId(id);
          }}
          onHover={(next) => {
            if (!pinned) setTime(next);
          }}
          onHoverEnd={() => {
            if (!pinned) setTime(null);
          }}
          onSeek={(next) => {
            setTime(next);
            setPinned(true);
          }}
        />
      </div>
      <div
        role="group"
        aria-label="Identify and pin a signal"
        className="flex flex-wrap gap-1.5"
      >
        {signals.map((signal) => (
          <button
            key={signal.id}
            type="button"
            aria-pressed={enabled && activeId === signal.id}
            className={`rounded px-2 py-1 text-left text-[11px] focus-visible:outline-2 focus-visible:outline-[#3388aa] ${enabled && activeId === signal.id ? "bg-black/[0.08] dark:bg-white/[0.1]" : "hover:bg-black/[0.04] dark:hover:bg-white/[0.05]"}`}
            onClick={() => {
              setEnabled(true);
              setActiveId(signal.id);
              setTime((value) => value ?? 30);
              setPinned(true);
            }}
          >
            <span
              aria-hidden="true"
              className="mr-1.5 inline-block h-1.5 w-3 rounded-full"
              style={{ background: signal.color }}
            />
            {signal.label}
          </button>
        ))}
      </div>
      <output
        className="block min-h-8 text-[11px] leading-relaxed"
        aria-live="polite"
      >
        {enabled && active
          ? `Identified: ${active.label}`
          : "No signal identified"}
        {pinned && time !== null ? ` · pinned at ${time.toFixed(2)} s` : ""}
      </output>
      <p className="text-[11px] leading-relaxed opacity-65">
        Hover within 8 px of a trace. Choose a signal button to identify it and
        pin the current shared time using keyboard or touch. Escape or Reset
        clears the pin and identity. The seventh signal has a gap at 24–28 s;
        its full name remains readable even beyond the five-row tooltip budget.
      </p>
    </div>
  );
}
```

```tsx
<EpisodeLineChart
  series={series}
  duration={duration}
  highlightOnHover
  highlightThresholdPx={8}
/>
```

<span id="behavior-contract" />

## Hover and selection

`highlightThresholdPx` measures distance in CSS pixels. Only visible segments
participate; missing samples break hit-testing. Zero-opacity traces and series
excluded from readouts cannot be identified by hover.

Connect a legend or table by controlling the series ID:

```tsx
const [activeSeriesId, setActiveSeriesId] = useState<string | null>(null)
<EpisodeLineChart
  series={series} duration={duration} time={time}
  highlightOnHover
  activeSeriesId={activeSeriesId}
  onActiveSeriesChange={setActiveSeriesId}
/>
```

Pointer movement and Escape request changes through `onActiveSeriesChange`.
Your state determines whether selection survives pointer leave. Identity and
time are separate: the example buttons also pin a time to keep values visible.
Readouts continue to use the nearest original sample.

## API additions to EpisodeLineChart

| Prop                   | Type                           | Default     | Purpose                                                              |
| ---------------------- | ------------------------------ | ----------- | -------------------------------------------------------------------- |
| `highlightOnHover`     | `boolean`                      | `false`     | Identify the nearest eligible trace on pointer movement.             |
| `highlightThresholdPx` | `number`                       | `8`         | Maximum distance in CSS pixels; invalid or nonpositive values use 8. |
| `activeSeriesId`       | `string \| null`               | local hover | Controlled identity; null explicitly clears it.                      |
| `onActiveSeriesChange` | `(id: string \| null) => void` | —           | Receive requested pointer, leave, or Escape changes.                 |

A controlled highlight can persist without hover. Supply a visible `time` to
show its values. See the [full chart API](/components/episode-line-chart.md#props)
for cursor and viewport control.
