# Parallel coordinates

Compare experiment settings and outcomes in one plot. Each line is a run;
its axes can mix continuous values such as learning rate with categories such
as optimizer.

## Experiment selection and table linkage

Select a line, button, or table checkbox below. They share selected experiment
IDs, which also survive hiding an axis.

```tsx file="ExperimentComparisonSpec.tsx"

const axes: ParallelCoordinatesAxis[] = [
  {
    id: "rate",
    label: "Learning rate",
    type: "continuous",
    formatValue: (v) => v.toExponential(1),
  },
  {
    id: "batch",
    label: "Batch size",
    type: "categorical",
    categories: ["32", "64", "128", "256"],
  },
  { id: "width", label: "Hidden units", type: "continuous" },
  {
    id: "optimizer",
    label: "Optimizer",
    type: "categorical",
    categories: ["SGD", "AdamW", "Adam"],
  },
  { id: "score", label: "Eval score", type: "continuous" },
];
// Synthetic fixed observations, shared by the explicit table-only baseline and enhanced view.
const experiments: ParallelCoordinatesSeries[] = [
  {
    id: "run-a",
    label: "run-a",
    values: {
      rate: 0.001,
      batch: "64",
      width: 256,
      optimizer: "AdamW",
      score: 84,
    },
  },
  {
    id: "run-b",
    label: "run-b",
    values: {
      rate: 0.0002,
      batch: "128",
      width: 512,
      optimizer: "Adam",
      score: 93,
    },
  },
  {
    id: "run-c",
    label: "run-c",
    values: {
      rate: 0.008,
      batch: "32",
      width: 128,
      optimizer: "SGD",
      score: 65,
    },
  },
  {
    id: "run-d",
    label: "run-d",
    values: {
      rate: 0.004,
      batch: "256",
      width: 384,
      optimizer: "AdamW",
      score: 78,
    },
  },
  {
    id: "run-e",
    label: "run-e",
    values: {
      rate: 0.0005,
      batch: "64",
      width: null,
      optimizer: "Adam",
      score: 89,
    },
  },
  {
    id: "run-f",
    label: "run-f",
    values: {
      rate: 0.002,
      batch: "128",
      width: 192,
      optimizer: "SGD",
      score: 72,
    },
  },
];
const controlClass =
  "rounded px-2 py-1 text-xs hover:bg-black/[0.04] dark:hover:bg-white/[0.05] focus-visible:outline-2 focus-visible:outline-[#3388aa]";

export function ExperimentComparisonSpec() {
  const [showChart, setShowChart] = useState(true);
  const [visibleAxisIds, setVisibleAxisIds] = useState(
    axes.map((axis) => axis.id),
  );
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
  const [onlySelected, setOnlySelected] = useState(false);
  const rows = onlySelected
    ? experiments.filter((row) => selectedIds.includes(row.id))
    : experiments;
  const toggleRow = (id: string) =>
    setSelectedIds((ids) =>
      ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id],
    );
  return (
    <div data-testid="parallel-comparison-demo" className="min-w-0 space-y-4">
      <div className="flex flex-wrap items-center justify-between gap-2 text-xs">
        <label className="flex items-center gap-2">
          <input
            type="checkbox"
            checked={showChart}
            onChange={(event) => setShowChart(event.target.checked)}
          />
          Show comparison chart
        </label>
        <div className="flex items-center gap-1">
          <button
            className={controlClass}
            type="button"
            onClick={() =>
              setSelectedIds(
                [...experiments]
                  .sort(
                    (a, b) => Number(b.values.score) - Number(a.values.score),
                  )
                  .slice(0, 2)
                  .map((row) => row.id),
              )
            }
          >
            Select top 2
          </button>
          <button
            className={controlClass}
            type="button"
            onClick={() => setSelectedIds([])}
          >
            Clear selection
          </button>
        </div>
      </div>
      {showChart ? (
        <>
          <fieldset className="flex flex-wrap items-center gap-x-4 gap-y-2 border-0 p-0 text-[11px]">
            <legend className="mb-2 font-medium">Visible dimensions</legend>
            {axes.map((axis) => (
              <label key={axis.id} className="flex items-center gap-1.5">
                <input
                  type="checkbox"
                  checked={visibleAxisIds.includes(axis.id)}
                  onChange={() =>
                    setVisibleAxisIds((ids) =>
                      ids.includes(axis.id)
                        ? ids.filter((id) => id !== axis.id)
                        : [...ids, axis.id],
                    )
                  }
                />
                {axis.label}
              </label>
            ))}
          </fieldset>
          <ParallelCoordinates
            title="Training sweep · 6 experiments"
            axes={axes}
            series={experiments}
            selectedIds={selectedIds}
            onSelectionChange={setSelectedIds}
            visibleAxisIds={visibleAxisIds}
          />
        </>
      ) : (
        <p className="rounded bg-black/[0.025] px-3 py-4 text-xs dark:bg-white/[0.035]">
          Table-only baseline — the same six synthetic experiments before adding
          ParallelCoordinates.
        </p>
      )}
      <div className="flex flex-wrap items-center justify-between gap-2 text-[11px]">
        <output aria-live="polite">
          {selectedIds.length} selected · {rows.length} shown
        </output>
        <label className="flex items-center gap-1.5">
          <input
            type="checkbox"
            checked={onlySelected}
            onChange={(event) => setOnlySelected(event.target.checked)}
          />
          Selected experiments only
        </label>
      </div>
      <div className="overflow-x-auto">
        <table
          className="w-full whitespace-nowrap text-left text-[11px]"
          aria-label="Experiment observations"
        >
          <thead>
            <tr className="border-b border-black/10 dark:border-white/10">
              <th scope="col" className="px-2 py-2 font-medium">
                Select
              </th>
              <th scope="col" className="px-2 py-2 font-medium">
                Experiment
              </th>
              {axes.map((axis) => (
                <th key={axis.id} scope="col" className="px-2 py-2 font-medium">
                  {axis.label}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {rows.map((row) => (
              <tr
                key={row.id}
                className={`border-b border-black/[0.05] dark:border-white/[0.06] ${selectedIds.includes(row.id) ? "bg-black/[0.035] dark:bg-white/[0.05]" : ""}`}
              >
                <td className="px-2 py-2">
                  <input
                    type="checkbox"
                    aria-label={`Select ${row.label} in table`}
                    checked={selectedIds.includes(row.id)}
                    onChange={() => toggleRow(row.id)}
                  />
                </td>
                <th scope="row" className="px-2 py-2 font-mono font-normal">
                  {row.label}
                </th>
                {axes.map((axis) => (
                  <td key={axis.id} className="px-2 py-2 font-mono">
                    {row.values[axis.id] == null
                      ? "Missing"
                      : axis.type === "continuous" && axis.formatValue
                        ? axis.formatValue(Number(row.values[axis.id]))
                        : String(row.values[axis.id])}
                  </td>
                ))}
              </tr>
            ))}
            {rows.length === 0 && (
              <tr>
                <td colSpan={7} className="px-2 py-4 text-center opacity-60">
                  No selected experiments. Select a trace, experiment button, or
                  table checkbox.
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
      <p className="text-[11px] leading-relaxed opacity-65">
        Synthetic data. run-e has no recorded hidden-unit value: its trace
        breaks at that axis. Axis visibility and table filtering preserve
        selection. Each row checkbox works with the keyboard.
      </p>
    </div>
  );
}
```

The example implements “Select top 2” and table filtering outside the chart.
Reuse the reported IDs for your own table or detail view.

## Data and controlled state

Define axes in display order and key each run's values by axis ID:

```tsx
const axes: ParallelCoordinatesAxis[] = [
  { id: 'rate', label: 'Learning rate', type: 'continuous' },
  { id: 'optimizer', label: 'Optimizer', type: 'categorical', categories: ['SGD', 'Adam'] },
  { id: 'score', label: 'Score', type: 'continuous', domain: [0, 100] },
]
const experiments = [{
  id: 'run-a', label: 'Run A',
  values: { rate: 0.001, optimizer: 'Adam', score: 89 },
}]
const [selectedIds, setSelectedIds] = useState<string[]>([])

<ParallelCoordinates
  axes={axes} series={experiments}
  selectedIds={selectedIds} onSelectionChange={setSelectedIds}
/>
```

Keep axis and experiment IDs unique and stable. Continuous values must be
numbers; convert numeric strings before rendering. Categorical values follow
`categories` from bottom to top.

<span id="behavior-contract" />

## Axes and missing values

Continuous axes fit finite data with [readable ticks](/components/numeric-axis.md).
Set a finite, increasing `domain` for a fixed scale. Invalid domains fall back
to automatic fitting; constant and empty data still receive a finite range.

Missing or non-finite values, unknown categories, and values outside a fixed
domain break the trace. A lone valid value appears as a point. Experiment
buttons retain the values in accessible text.

Wire both `selectedIds` and `onSelectionChange` to apply selection changes.
Without the callback, selection is read-only. `visibleAxisIds` filters the axes
without changing their declared order.

## Props

| Prop                | Type                                   | Default                   | Purpose                                                               |
| ------------------- | -------------------------------------- | ------------------------- | --------------------------------------------------------------------- |
| `axes`              | `readonly ParallelCoordinatesAxis[]`   | required                  | Axes in display order.                                                |
| `series`            | `readonly ParallelCoordinatesSeries[]` | required                  | Experiments with stable IDs, labels, values, and optional CSS colors. |
| `selectedIds`       | `readonly string[]`                    | `[]`                      | Selected experiment IDs.                                              |
| `onSelectionChange` | `(ids: string[]) => void`              | —                         | Receive the requested selection.                                      |
| `visibleAxisIds`    | `readonly string[]`                    | all axes                  | Axes to display, in `axes` order.                                     |
| `title`             | `string`                               | `'Experiment comparison'` | Visible and accessible chart name.                                    |
| `className`         | `string`                               | —                         | Root CSS classes.                                                     |

Use at least two visible axes. The plot scrolls horizontally when needed;
brushing and axis-reordering controls are not included. Start with a small
comparison set.
