---
url: https://nsh.dev/ru/elements/plot
description: "Компонент Plot из nsh/base/plot, его элементы Line, Area, Bar, Rule, Dot, Cell, Grid, Pie, Arc, RadialLine, RadialGrid, Sankey и Treemap, а также PlotAxis и PlotLabel: диаграммы, нативно отрисованные по строкам приложения, со шкалами осей и их подписями, текстами в точках данных, строкой, ячейкой или сектором под указателем, анимацией и без собственного внешнего вида."
demo: https://nsh.dev/play/index.html#el-plot
---

# Plot

Plot — это каркас диаграммы: строки данных, которые она показывает, шкалы её осей x и y, указатель над ней и её движение. Её метки рисуют то, что она содержит: `Line` по серии, `Area` между серией и базовой линией, `Bar` на строку в полосах шкалы band, `Rule` на строку от начала до конца (фитиль свечи), `Dot` на строку там, где сходятся два её значения (точки диаграммы рассеяния, пузыри пузырьковой диаграммы), `Cell` на строку в полосах двух категорий, окрашенный по её значению (тепловая карта), `Grid` из линий поперёк, `Pie` из долей строк (кольцевая диаграмма с отверстием), `Arc` между двумя углами и `RadialLine` вокруг центра с кольцами и лучами `RadialGrid` позади него (радар), `Sankey` потоков между строками и `Treemap` из вложенных прямоугольников, где площадь каждого листа равна его значению (карта рынка); `PlotAxis` подписывает край делениями своей шкалы, а `PlotLabel` пишет тексты в точках шкал. Каждая метка — один canvas, заполняющий plot и рисуемый нативно по строкам: строки приложения читаются один раз, когда оно их задаёт, затем масштабируются, записываются и рисуются в Rust, так что диаграмма из тысячи точек не запускает JS на каждую точку. Указатель тоже отслеживается в Rust: перекрестная линия наведения, точки и подсказка движутся, плавно появляются и исчезают на аниматоре платформы, а приложение узнаёт лишь о новой строке и ни о чём больше. Внешний вид определяет приложение: метки рисуются свойствами заливки CSS для фигур SVG (`stroke-*`, `fill-*`), `currentColor` там, где они не заданы, area или bar показывает фон (градиент, `bg-linear-*`) внутри своей формы, а части наведения (в том числе точка каждой метки) и подписи, засечки и линии осей стилизуются и заполняются приложением.

```rust
use nsh_base::{
    plot::{area, grid, line, plot},
    theme,
};
use nsh_core::{element::{RowAccessor, Rows}, prelude::*};

fn week(visitors: Vec<f64>, hovered: Signal<Option<u32>>) -> impl IntoElement {
    let rows = Rows::from_columns(visitors.len(), [("visitors".into(), visitors)]);
    plot()
        .data(rows)
        .y(RowAccessor::key("visitors"))
        .accessibility_label("Visitors per day")
        .h(px(160.0))
        .on_hover_change(move |index: &Option<u32>| hovered.set(*index))
        .child(grid().y(vec![0.0, 1500.0, 3000.0]).stroke_color(move || theme().border))
        .child(area().fill_color(move || theme().chart_1.opacity(0.2)))
        .child(line().stroke_color(move || theme().chart_1).stroke_width(px(2.0)))
}
```

```tsx
import { mount, signal, Text, View } from "nsh";
import { Area, Grid, Line, Plot } from "nsh/base/plot";

const rows = [
  { day: "Mon", visitors: 1820 },
  { day: "Tue", visitors: 2140 },
  { day: "Wed", visitors: 1960 },
  { day: "Thu", visitors: 2480 },
  { day: "Fri", visitors: 2310 },
];

function App() {
  const hovered = signal<number | null>(null);
  return (
    <View class="gap-2 p-4">
      <Text class="text-sm text-muted-foreground tabular-nums">
        {hovered() == null ? "Visitors" : `${rows[hovered()!].day}: ${rows[hovered()!].visitors}`}
      </Text>
      <Plot
        data={rows}
        y="visitors"
        yScale={{ type: "linear", domain: [0, 3000] }}
        accessibilityLabel="Visitors per day, highest on Thursday"
        onHoverChange={({ index }) => hovered.set(index)}
        crossLineClass="w-px h-full bg-border"
        dotClass="size-2.5 rounded-full border-2 border-background bg-chart-1"
        class="h-40"
      >
        <Grid y={[0, 1500, 3000]} class="stroke-border" />
        <Area class="fill-chart-1/20" />
        <Line class="stroke-chart-1 stroke-2" />
      </Plot>
    </View>
  );
}

mount(App);
```

`Plot`, `Line`, `Area`, `Bar`, `Rule`, `Dot`, `Cell`, `Grid`, `Pie`, `Arc`, `RadialLine`, `RadialGrid`, `Sankey`, `Treemap`, `PlotAxis` и `PlotLabel` приходят из `nsh/base/plot` — отдельного модуля [`nsh/base`](https://nsh.dev/translations/ru/modules/nsh-base.md) (в Rust — `nsh_base::plot`). В TypeScript props plot обобщены по типу его строк: имена полей проверяются по нему, а функция строки получает тип строки. В Rust `plot()` создаёт его с сеттером на каждый prop, строки — это `Rows` (столбцы чисел по ключу), значения — `RowAccessor` (ключ или функция индекса строки), а метки принимают типизированные методы заливки (`.stroke_color(..)`, `.fill_color(..)`, `.stroke_width(..)`).

<a id="plot-1"></a>

## Plot

| Свойство | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
| `data` | `.data(..)` | `T[]` | (обязательно) | Строки, один объект на точку. Метки читают свои значения один раз, когда приложение задаёт строки; строка без значения (поле, не являющееся числом, `null`) пропускается. |
| `x` | `.x(..)` | `keyof T \| (d: T, index: number) => number` | индекс строки | Что размещает строку вдоль оси x на шкале x `linear`. Шкала `point` размещает строки по порядку. |
| `y` | `.y(..)` | `keyof T \| (d: T, index: number) => number` | нет | Что рисуют метки, если не указано своё. |
| `xScale` | `.x_scale(..)` | `Scale` | `{ type: "point" }` | Как ось x размещает строки: `point` (по порядку, равномерно от левого края к правому; одна строка — посередине), `linear` с необязательным `domain` или `band` (полоса на строку — для столбцов, [Bar](#bar); или полоса на категорию — для ячеек, [Cell](#cell)). При шкале y `band` по умолчанию используется `linear`, для горизонтальных столбцов. `reverse: true` направляет её от правого края ([Обратные шкалы](#reversed-scales)). |
| `yScale` | `.y_scale(..)` | `Scale` | `{ type: "linear" }` | Как ось y размещает значения: наименьшее значение домена — у нижнего края, наибольшее — у верхнего. Без `domain` — протяжённость того, что рисуют метки. `band` укладывает полосу на строку от верхнего края вниз, для горизонтальных столбцов. `reverse: true` направляет её наоборот: наименьшее значение у верхнего края, строки — от нижнего края вверх ([Обратные шкалы](#reversed-scales)). |
| `y2Scale` | `.y2_scale(..)` | `LinearScale` | нет | Вторая шкала y, `{ type: "linear", domain?, reverse? }`, для меток, которые на неё ссылаются (`scale="y2"`): правая шкала диаграммы с двумя осями ([Две оси](#dual-axes)). Без `domain` — протяжённость значений на ней. |
| `alignTicks` | `.align_ticks(..)` | `boolean` | `true` | Пока ни одна из шкал y не задаёт домен и метки есть на обеих, обе расширяются до одинаковых круглых шагов, чтобы их деления попадали на одни и те же линии ([Две оси](#dual-axes)). `false` оставляет каждой протяжённость её значений. |
| `interactive` | `.interactive(..)` | `boolean` | `true` | Plot следует за указателем и клавиатурой: сообщает строку под ним и показывает части наведения, а также является остановкой Tab, стрелки которой двигают наведение ([Клавиатура](#keyboard)). `false` рисует только метки (спарклайн в списке), а нажатия на него уходят окружающим view. Предлагает ли plot клавиатуру вообще, задаётся при его создании. |
| `motion` | `.motion(..)` | `PlotMotion` | нет | Тайминг появления, плавного появления и исчезновения наведения и пружина, на которой наведение скользит, когда его двигают клавиши (ниже). |
| `appearGeneration` | `.appear_generation(..)` | `number \| string` | нет | Включает появление: метки отрисовываются при первом показе plot и снова при каждом изменении этого значения (другой символ, другой период). Внутри [`PlotAppearScope`](#plotappearscope) поколение, уже завершившее там появление, показывается сразу целиком. |
| `accessibilityLabel` | `.accessibility_label(..)` | `string` | нет | Что скрин-ридеры сообщают о диаграмме: что она показывает и что выделяется. |
| `accessibilityValue` | `.accessibility_value(..)` | `{ min?, max?, now?, text? }` | нет | Что скрин-ридеры слышат как её значение: слова приложения о строке под указателем. |
| `crossLineClass`, `crossLineStyle` | `.cross_line_class(..)`, `.cross_line_style(..)` | `string`, `StyleProp<ViewStyle>` | нет | Перекрестная линия: view высотой с plot, центрированный на строке под указателем. На шкале band он лежит в полосе строки и имеет ширину полосы (поперёк plot — на шкале y типа band). |
| `dotClass`, `dotStyle` | `.dot_class(..)`, `.dot_style(..)` | `string`, `StyleProp<ViewStyle>` | нет | Точки наведения: view на каждый `Line`, `Area` и `RadialLine`, центрированный на его точке в строке под указателем; view для `Dot` по размеру равен его точке под указателем. `hoverDotClass` / `hoverDotStyle` метки заменяют их для её собственной точки. |
| `tooltipContent` | `.tooltip_content(..)` | `JSX.Element` | нет | Подсказка приложения, размещаемая через `tooltipPlacement`, движущаяся и плавно появляющаяся вместе с перекрестной линией. |
| `tooltipPlacement` | `.tooltip_placement(..)` | `"top" \| "pointer"` | `"top"` | Где находится подсказка: вверху plot, по центру перекрестной линии; или рядом с указателем — со стороны перекрестной линии, обращённой к середине plot, ниже указателя в верхней половине и выше него в нижней. Вверху она остаётся между левым и правым краями plot (её отступ от верхнего края определяет приложение: тег под plot занимает высоту plot и больше); рядом с указателем она остаётся внутри plot. При работе с клавиатуры она находится рядом с точкой первой линии или области в строке (середина полосы столбца, середина сектора pie). |
| `tooltipOffset` | `.tooltip_offset(..)` | `number` | `0` | Расстояние между подсказкой и тем, что её размещает: верхний край для `"top"`, перекрестная линия и указатель для `"pointer"`. |
| `colorScale` | `.color_scale(..)` | `ColorScale` | нет | Цвета меток: по категориям, которые читает их `color`, как `scaleOrdinal` в d3 (`{ type: "ordinal", domain?, range, unknown? }`, [Цвета по категориям](#colors-by-category)), или по числам, которые `value` у `Cell` читает вдоль градиента, как `scaleSequential` и `scaleDiverging` в d3 (`{ type: "sequential" \| "diverging", … }`, [Цвета по градиенту](#colors-along-a-ramp)). |
| `children` | `.child(..)` | `JSX.Element` | нет | Метки, рисуемые по порядку друг поверх друга, и всё, что приложение кладёт поверх них. |
| `class`, `style` | методы стилей | `string`, `StyleProp<ViewStyle>` | нет | Размер и внешний вид plot; метки наследуют его свойства заливки так же, как CSS их наследует. Классы `focus:` / `focus-visible:` и `focusStyle` рисуют его кольцо фокуса. |

| Событие | Rust | Данные | Когда |
|---|---|---|---|
| `onHoverChange` | `.on_hover_change(..)` | `{ index: number \| null }` | Указатель оказался над другой строкой: индекс строки, ближайшей к нему вдоль оси x (вдоль оси y, когда полосы есть только у неё), сектора [pie](#pie-and-arc) под ним, [dot](#dot), центр которого ближе всего к нему, [cell](#cell) под ним (в промежутке между двумя ячейками — ближайшей) или строки [radial line](#radialline-and-radialgrid), ближайшей к нему по углу; `null`, когда он покидает plot (для pie — её секторы; для radial line — её внешний радиус). |

`Scale` — это `{ type: "point"; reverse? } | { type: "linear"; domain?: [number, number]; reverse? } | { type: "band";
domain?: string[]; paddingInner?; paddingOuter?; maxBandWidth?; bandCount?; groupPadding?; reverse? }`. A `linear`
`domain` шкалы — это протяжённость двух её значений в любом порядке; домен из одного значения ставит его посередине. `domain` шкалы `band` — это категории её полос по порядку ([Cell](#cell)); остальные её опции описаны в [Bar](#bar).

Функция строки (`y={(d) => d.high - d.low}`) вызывается один раз на строку при изменении строк или функции, на следующем такте, но никогда во время отрисовки plot или отслеживания указателя: держите её чистой (та же строка — то же число). Имя поля читается вообще без JS.

<a id="reversed-scales"></a>

### Обратные шкалы

`reverse: true` направляет шкалу от другого края, как шкалы visx принимают `reverse`, а шкалы d3 — диапазон, записанный наоборот. На оси x первая строка, или наименьшее значение домена, находится у правого края; на оси y наименьшее значение домена — у верхнего края, а строки идут от нижнего края вверх. Всё, что размещено на шкале, следует ей: метки, основание столбцов и направление их роста, базовая линия области, линии сетки, деления `PlotAxis`, строка, которую находит наведение, стрелки ([Клавиатура](#keyboard)) и переходы к новому домену. Столбцы свисают от верхнего края на обращённой шкале y типа `linear`, а горизонтальные столбцы растут влево от правого края на обращённой шкале x типа `linear`:

```tsx
<Plot
  data={depths}
  y="depth"
  xScale={{ type: "band", paddingInner: 0.2 }}
  yScale={{ type: "linear", domain: [0, 40], reverse: true }}
  class="h-32"
>
  <Bar class="fill-chart-2" cornerRadii={{ bottomLeft: 3, bottomRight: 3 }} />
</Plot>
```

При появлении plot линии и области рисуются от края, с которого начинается шкала x, — от правого, если она обращена.

<a id="dual-axes"></a>

### Две оси

<!-- demo: el-plot-dual -->

У plot есть вторая шкала y для значений в других единицах: объёмы рядом с ценой, выручка рядом с маржой. Каждая часть, размещающая значения по y, принимает `scale`: `"y"` (по умолчанию, `yScale` plot) или `"y2"` (его `y2Scale`). `PlotAxis` справа с `scale="y2"` подписывает её:

```tsx
<Plot data={days} xScale={{ type: "band", paddingInner: 0.3 }} class="mx-10 mb-6 h-44">
  <Grid class="stroke-border" />
  <Bar value="volume" class="fill-chart-2/50" />
  <Line y="close" scale="y2" class="stroke-chart-1 stroke-2" />
  <PlotAxis placement="left" format={(v) => `${v}M`} labelClass="pr-2 text-xs text-muted-foreground" />
  <PlotAxis placement="right" scale="y2" labelClass="pl-2 text-xs text-muted-foreground" />
</Plot>
```

Домен каждой шкалы свой: `domain` приложения или протяжённость значений на ней, и каждая переходит к новому с помощью `motion.update`. Пока ни одна шкала не задаёт домен, plot раскладывает обе на одинаковое число круглых шагов (1, 2 или 5, умноженные на степень десяти, шаг примерно на каждые 32 точки высоты): когда обе содержат ноль, он попадает на одну и ту же линию на обеих; иначе шкала, начинающаяся с нуля (объёмы), получает дополнительные шаги вверх, а другая (цена) — с обеих сторон. Деления осей — это эти шаги, а `Grid` без `x` и `y` рисует свои линии по ним, так что одна сетка служит обеим осям. `alignTicks={false}` отключает это; `domain`, заданный приложением, оставляет обе шкалы как есть.

Наведение находит одну строку; точка каждой метки лежит на её собственной шкале, а показания выводит приложение, которое читает оба значения строки, о которой сообщает `onHoverChange`. Столбцы на обеих шкалах стоят рядом в каждой полосе, а `stack` складывает в стопку только столбцы на своей шкале. `y2Scale` линейна: у plot, ось y которого имеет полосы, второй шкалы y нет.
<a id="line-area-and-grid"></a>

## Line, Area и Grid

| Марка | Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|---|
| `Line` | `y` | `string \| (d, index) => number` | `y` графика | Что она рисует: по одной точке на строку со значением; строки без значения пропускаются. |
| `Line`, `Area` | `data`, `x` | `T[]`, `string \| (d, index) => number` | строки графика | Строки собственной серии (другие значения x), размещаемые на шкалах графика по собственному `x` (без него — по индексу строки). |
| `Line`, `Area` | `curve` | `"natural" \| "linear" \| "stepAfter"` | `"natural"` | Как она соединяет свои точки: плавной кривой, прямыми отрезками или ступенями, которые держат каждое значение до следующей строки. Нижняя граница области идёт по той же кривой. |
| `Line` | `dot`, `dotClass`, `dotStyle` | `boolean`, `string`, `StyleProp<ViewStyle>` | `false` | Маркер на каждой точке: view с центром в ней; его размер, фон и рамка задаются приложением. |
| `Line`, `Area` | `hoverDotClass`, `hoverDotStyle` | `string`, `StyleProp<ViewStyle>` | `dotClass`, `dotStyle` графика | Её маркер наведения на её точке в строке под указателем: маркер каждой серии — своего цвета. Каждый из них заменяет соответствующий маркер графика для этой марки (цвет марки поверх размера и кольца графика); `hidden` не показывает ни одного. |
| `Area` | `backgroundClass`, `backgroundStyle` | `string`, `StyleProp<ViewStyle>` | нет | Её фон: блок вокруг её фигуры (от первой точки до последней, от самой высокой точки до базовой линии или, у полосы, до самой низкой точки нижней границы), чей фон (`bg-*`, `bg-linear-to-b from-… to-…`, `backgroundImage`) виден только внутри фигуры, под её `fill`. Если он задан, `fill` по умолчанию равен `none`. |
| `Area` | `y1` | `string \| (d, index) => number` | `y` графика | Что образует её верхнюю границу. |
| `Area` | `y0` | `number \| string \| (d, index) => number` | нижний край шкалы y | Её нижняя граница: число задаёт базовую линию на шкале y; поле или функция дают каждой строке своё нижнее значение, и тогда область становится полосой (от минимума дня до его максимума, диапазон прогноза). Без неё область замыкается на край, где лежат наименьшие значения шкалы y: нижний край, а на обращённой шкале — верхний (область, свисающая сверху). Строка без значения на любом из краёв пропускается. |
| `Line`, `Area`, `Grid` | `scale` | `"y" \| "y2"` | `"y"` | Шкала y, вдоль которой идут её значения: `yScale` графика или вторая, `y2Scale` ([Двойные оси](#dual-axes)). |
| `Grid` | `x` | `number[]` | нет | Где проходят её вертикальные линии, в значениях шкалы x (индексы строк на шкале `point`). |
| `Grid` | `y` | `number[]` | нет | Где проходят её горизонтальные линии, в значениях шкалы y. Если не заданы ни `x`, ни `y`, линии идут по делениям её шкалы — там, где их ставит `PlotAxis` без `ticks` (по x для горизонтальных столбцов). |
| `Grid` | `ticks` | `number` | значение оси по умолчанию | Примерно сколько линий она рисует, если не заданы ни `x`, ни `y`, — как `ticks` у `PlotAxis`. |
| `Line`, `Area` | `color` | `string \| (d, index) => string` | нет | Категория её строк, чей цвет берётся из `colorScale` графика: категория первой строки задаёт `stroke` линии (`fill` области), если ничто другое его не задаёт ([Цвета по категориям](#colors-by-category)). |

Каждая марка заполняет график и рисует своими [свойствами заливки SVG](https://nsh.dev/translations/ru/modules/style-reference.md#svg-paint); собственные свойства марки
приоритетнее свойств графика, как при наследовании в CSS:

| Марка | Рисует с помощью | Если не задано |
|---|---|---|
| `Line` | `stroke`, `strokeWidth`, `strokeDasharray` | `currentColor`, шириной 1, сплошная |
| `Area` | `fill` (поверх её части `background`); её верхняя граница — `stroke`, `strokeWidth`, `strokeDasharray` | `fill` `currentColor` (`none` рядом с фоном); без обводки |
| `Grid` | `stroke`, `strokeWidth`, `strokeDasharray` | `currentColor`, шириной 1, сплошная; каждая линия ложится на целые пиксели устройства, поэтому тонкая остаётся чёткой |

Значения, которые рисует `Line` или `Area`, а также `y0` области (её базовая линия или нижнее значение каждой строки),
образуют домен шкалы y, если у графика его нет; значения сетки — нет. Марка перерисовывается, только когда меняется то,
что она рисует (её строки, значения, кривая, размер или шкалы графика, её заливка); указатель двигает части наведения
и никогда не перерисовывает марку.

Полоса — это `Area`, у которой `y0` читает строки: её верхняя граница идёт через `y1`, нижняя — обратно через `y0`,
обе с `curve` области, а appear прорисовывает обе от начального края. Её маркер наведения стоит на верхней границе;
`Line` поверх неё отмечает среднее значение:

```tsx
<Plot data={days} y="close">
  <Area y1="high" y0="low" class="fill-chart-1/15" hoverDotClass="hidden" />
  <Line class="stroke-chart-1 stroke-2" />
</Plot>
```

`fill` — как в CSS, как в браузере: цвет. Градиент под линией — это фон области, так же как страница показывает градиент
в фигуре (`background-image` блока под `clip-path`): `backgroundClass="bg-linear-to-b from-chart-1/20
to-transparent"` плавно переходит от самой высокой точки области к её базовой линии, в токенах темы и цветах
оформления, как фон любого view.

## Bar

`Bar` рисует по столбцу на строку в полосе этой строки на шкале `band`: вертикально на шкале band x, горизонтально на
шкале band y (тогда шкала x — `linear`). Каждый столбец идёт от `base` до значения строки на другой шкале, поэтому
отрицательное значение растёт в другую сторону, а у строки без значения столбца нет. На обращённой шкале значений
столбцы растут вниз от верхнего края или влево от правого, а обращённая шкала band ставит полосу первой строки справа
или внизу ([Обращённые шкалы](#reversed-scales)).

```tsx
import { Bar, Grid, Plot } from "nsh/base/plot";

const sales = [
  { month: "Jan", online: 42, store: 31 },
  { month: "Feb", online: 51, store: 28 },
  { month: "Mar", online: 48, store: 36 },
];

<Plot
  data={sales}
  xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15, groupPadding: 0.1 }}
  yScale={{ type: "linear", domain: [0, 60] }}
  class="h-40"
>
  <Grid y={[20, 40, 60]} class="stroke-border" />
  <Bar value="online" class="fill-chart-1" cornerRadii={{ topLeft: 4, topRight: 4 }} />
  <Bar value="store" class="fill-chart-2" cornerRadii={{ topLeft: 4, topRight: 4 }} />
</Plot>
```

Столбцы вне стека стоят рядом в каждой полосе в порядке марок; столбцы с одинаковым `stack` складываются, и столбец
каждой строки начинается там, где заканчивается предыдущий в стеке:

```tsx
<Bar value="online" stack="sales" class="fill-chart-1" />
<Bar value="store" stack="sales" class="fill-chart-2" cornerRadii={{ topLeft: 4, topRight: 4 }} label={(d) => `${d.online + d.store}`} />
```

| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
| `value` | `string \| (d, index) => number` | `y` графика | Что показывает каждый столбец. |
| `base` | `number \| string \| (d, index) => number` | `0` | Значение, от которого растут её столбцы, на шкале значений: одно для всех строк либо поле или функция, дающие каждой строке своё, так что каждый столбец тянется от базы строки до её значения (у строки без него столбца нет). Оно учитывается в домене. Стек растёт от базы своего первого столбца, строка за строкой. |
| `stack` | `string` | нет | Стек, к которому она присоединяется: столбцы с одинаковым `stack` складываются по порядку (строка без значения ничего не добавляет). |
| `scale` | `"y" \| "y2"` | `"y"` | Шкала y, вдоль которой идут значения её вертикальных столбцов ([Двойные оси](#dual-axes)); столбцы на обеих шкалах стоят рядом, а стек складывает только столбцы своей шкалы. |
| `cornerRadii` | `number \| { topLeft?, topRight?, bottomRight?, bottomLeft? }` | `0` | Радиус каждого угла её столбцов; `{ topLeft: 4, topRight: 4 }` скругляет концы вертикальных столбцов. Радиус больше половины столбца подгоняется под него. |
| `stagger` | `number` | `0` | При appear графика столбцы стартуют один за другим, строка за строкой, в пределах этой доли его длительности (от 0 до 0.95), и каждый растёт оставшееся время; 0 растит их одновременно. |
| `inactiveOpacity` | `number` | нет | Непрозрачность её столбцов, кроме строки под указателем, пока указатель находится над графиком, чтобы эта строка выделялась: она плавно появляется и исчезает вместе с наведением и переходит от столбца к столбцу вместе с ним. |
| `label` | `string \| (d, index) => string` | нет | Текст на конце каждого столбца по значению: поле (его текст или число) либо функция, возвращающая текст, который форматирует приложение. |
| `labelClass`, `labelStyle` | `string`, `StyleProp<TextStyle>` | нет | Часть `label`: текст, центрированный по столбцу, над концом вертикального столбца (под столбцом, растущим вниз), после горизонтального столбца (перед растущим назад). Её отступ — расстояние до столбца. |
| `backgroundClass`, `backgroundStyle` | `string`, `StyleProp<ViewStyle>` | нет | Её фон: блок каждого столбца, чей фон (`bg-linear-to-t from-… to-…`, от низа столбца до его верха) виден только внутри столбца, под её `fill`. Если он задан, `fill` по умолчанию равен `none`. |
| `color` | `string \| (d, index) => string` | нет | Категория каждого столбца, чей цвет берётся из `colorScale` графика: `fill` столбца, если ничто другое его не задаёт ([Цвета по категориям](#colors-by-category)). |

Шкала band размещает по полосе на каждую строку графика по порядку или по полосе на каждую категорию её `domain`, как
`scaleBand` в d3:

| Опция | По умолчанию | Описание |
|---|---|---|
| `domain` | нет | Категории её полос по порядку (`scaleBand().domain()` в d3): [`Cell`](#cell) попадает в полосу категории своей строки, а `PlotAxis` выводит категорию каждой полосы; категория, записанная дважды, сохраняет первое место. Без неё полосы — это строки, а у ячеек — категории, которые ячейки называют вдоль оси, в порядке, в котором их приносят строки. |
| `paddingInner` | `0` | Промежуток между соседними полосами, доля шага каждой полосы (от 0 до 1). |
| `paddingOuter` | `0` | Промежуток перед первой полосой и после последней, доля шага. Половина `paddingInner` ставит середины полос на равные шаги поперёк графика. |
| `maxBandWidth` | нет | Максимальная ширина полосы в пунктах: несколько полос на широком графике остаются узкими, каждая посередине своего места. |
| `bandCount` | строки | Число полос, на которое рассчитана ось, если их больше, чем строк: строки занимают первые и сохраняют свою ширину и место по мере поступления строк. |
| `groupPadding` | `0` | Промежуток между столбцами, стоящими рядом в полосе, доля места каждого столбца. |
| `reverse` | `false` | Полосы идут от противоположного края: первой строки — справа на оси x, внизу на оси y; столбцы, стоящие рядом в полосе, сохраняют свой порядок. |

Столбец рисуется своей `fill` (`fill-*`; наследуется от графика, как наследование в CSS), `currentColor` если не задано
(цвет его категории с `color`), и не показывает маркер наведения. На шкале band наведение находит полосу под указателем,
а перекрестная линия графика располагается в этой полосе и имеет её ширину: `crossLineClass="w-full h-full bg-foreground/5"` подкрашивает полосу, `w-px` оставляет
линию посередине. Значения и база столбцов образуют домен шкалы значений, если у графика его нет. При appear графика
каждый столбец вырастает из базы своего стека; движение, затухание и скольжение выполняются на аниматоре платформы,
а указатель не перерисовывает ни одного столбца.

<a id="ranges"></a>

### Диапазоны

<!-- demo: el-plot-ranges -->

Если `base` — поле или функция, каждый столбец охватывает два значения своей строки: ступень водопада от накопленной
суммы до неё к накопленной после, тело свечи от открытия до закрытия, задача от начала до конца. Столбец растёт от
собственной базы при появлении, а его подпись стоит на конце по значению: над растущим столбцом и под падающим.

```tsx
const bridge = [
  { step: "Revenue", from: 0, to: 120, kind: "total" },
  { step: "Costs", from: 120, to: 72, kind: "down" },
  { step: "Other", from: 72, to: 81, kind: "up" },
  { step: "Profit", from: 0, to: 81, kind: "total" },
];

<Plot
  data={bridge}
  xScale={{ type: "band", paddingInner: 0.3 }}
  colorScale={{ type: "ordinal", domain: ["total", "up", "down"], range: ["#64748b", "#16a34a", "#dc2626"] }}
  class="h-40"
>
  <Bar value="to" base="from" color="kind" label={(d) => `${d.to - d.from}`} labelClass="text-xs pb-1" />
</Plot>
```

Воронка — это горизонтальные столбцы по центру: `base={(d) => -d.users / 2}` и `value={(d) => d.users / 2}` на шкале band y.

## Rule

`Rule` рисует по прямой линии на строку вдоль оси значений, от `base` до значения строки: посередине полосы строки на
шкале band (поперёк графика на шкале band y), на месте строки вдоль оси x на шкале point или linear. Фитиль свечи, ус
box plot, планка погрешности и стебель lollipop — это Rule; свечной график — это `Bar` поверх `Rule`:

```tsx
import { Bar, Plot, Rule } from "nsh/base/plot";

<Plot
  data={days}
  xScale={{ type: "band", paddingInner: 0.4 }}
  colorScale={{ type: "ordinal", domain: ["up", "down"], range: ["#16a34a", "#dc2626"] }}
  class="h-48"
>
  <Rule value="high" base="low" color={(d) => (d.close >= d.open ? "up" : "down")} />
  <Bar value="close" base="open" color={(d) => (d.close >= d.open ? "up" : "down")} />
</Plot>
```

| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
| `value` | `string \| (d, index) => number` | `y` графика | Где заканчивается каждая линия. У строки без значения линии нет. |
| `base` | `number \| string \| (d, index) => number` | `0` | Где начинается каждая линия: одно значение для всех строк (стебель lollipop от нулевой линии) либо поле или функция, дающие каждой строке своё (минимум свечи). Оно учитывается в домене. |
| `scale` | `"y" \| "y2"` | `"y"` | Шкала y, вдоль которой идут её значения ([Двойные оси](#dual-axes)). |
| `stagger` | `number` | `0` | При appear графика линии стартуют одна за другой, строка за строкой, в пределах этой доли его длительности (от 0 до 0.95), и каждая растёт от своего начала оставшееся время. |
| `inactiveOpacity` | `number` | нет | Непрозрачность её линий, кроме строки под указателем, пока указатель находится над графиком, как у `Bar`. |
| `color` | `string \| (d, index) => string` | нет | Категория каждой линии, чей цвет берётся из `colorScale` графика: `stroke` линии, если ничто другое его не задаёт. |

Rule проводит обводку своей `stroke` (`stroke-*`, `strokeWidth`, `strokeDasharray`; наследуется от графика, как наследование в CSS), `currentColor` если
не задано, и не показывает маркер наведения. Её значения и база образуют домен шкалы значений, если у графика его нет;
она растёт от своего начала при appear графика и движется вместе с доменами шкал, на аниматоре платформы.
## Dot

<!-- demo: el-plot-dots -->

`Dot` рисует по точке на каждую строку там, где встречаются два её значения. Её `x` на `linear`-шкале x и её `value` вверх по оси y образуют диаграмму рассеяния. На band-шкале x каждая точка стоит посередине полосы своей строки. На band-шкале y (шкала x `linear`) значение идёт вдоль x, а полоса строки уходит вниз по графику: получается dot plot. Радиус `r` — это одно число для всех точек либо значение каждой строки, которое `rScale` превращает в радиус: получается пузырьковая диаграмма. У строки без значения точки нет.

```tsx
import { Dot, Grid, Plot } from "nsh/base/plot";
import { token } from "nsh";

const sessions = [
  { minutes: 12, trades: 5, platform: "iOS" },
  { minutes: 8, trades: 3, platform: "Android" },
  { minutes: 26, trades: 11, platform: "Web" },
  { minutes: 17, trades: 8, platform: "iOS" },
];

<Plot
  data={sessions}
  x="minutes"
  xScale={{ type: "linear", domain: [0, 40] }}
  yScale={{ type: "linear", domain: [0, 20] }}
  colorScale={{ type: "ordinal", domain: ["iOS", "Android", "Web"], range: [token("chart-1"), token("chart-2"), token("chart-3")] }}
  class="h-48"
>
  <Grid x={[10, 20, 30]} y={[5, 10, 15]} class="stroke-border" />
  <Dot
    value="trades"
    r={4}
    color="platform"
    inactiveOpacity={0.3}
    class="stroke-card stroke-2"
    hoverDotClass="rounded-full outline-2 outline-offset-1 outline-foreground"
  />
</Plot>
```

Если `r` — поле или функция, значение каждой строки задаёт размер её точки. `rScale={{ type: "sqrt", range: [min, max] }}` отображает квадратные корни значений на радиусы в пунктах, как это делает `scaleSqrt` в d3. Поэтому при диапазоне от 0 площадь пузыря пропорциональна его значению, а диапазон от значения больше 0 не даёт самым маленьким пузырям пропасть из виду. Домен — от 0 до наибольшего значения, если шкала не задаёт свой. Крупные точки рисуются первыми, так что мелкие остаются видны поверх них:

```tsx
<Plot
  data={countries}
  x="gdp"
  xScale={{ type: "linear", domain: [-10, 95] }}
  yScale={{ type: "linear", domain: [48, 90] }}
  colorScale={{ type: "ordinal", range: [token("chart-1"), token("chart-2"), token("chart-3"), token("chart-4")] }}
  class="h-56"
>
  <Dot value="life" r="population" rScale={{ type: "sqrt", range: [3, 24] }} color="region" class="stroke-card" />
</Plot>
```

Значения точек образуют домен шкалы значений, если у графика его нет, причём без привязки к 0, так что оси диаграммы рассеяния подходят под её точки. Когда точки большие, задайте графику `domain` с некоторым запасом вокруг значений: пузырь на краю домена рисуется за границей графика.

Две отметки в одних и тех же полосах дают диаграммы, которые сравнивают два значения строки. Lollipop — это `Rule` от 0 под `Dot` на её значении, а dumbbell — это `Rule` от одного значения к другому с `Dot` на каждом конце, на band-шкале y:

```tsx
<Plot data={stocks} yScale={{ type: "band", paddingInner: 0.3 }} xScale={{ type: "linear", domain: [-50, 80] }} class="h-48">
  <Rule value="high" base="low" class="stroke-border stroke-2" />
  <Dot value="before" r={3.5} class="fill-card stroke-muted-foreground" hoverDotClass="hidden" />
  <Dot value="now" r={5} class="fill-chart-1 stroke-card stroke-2" hitRadius={24} />
</Plot>
```

| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
| `value` | `string \| (d, index) => number` | `y` графика | Где находится каждая точка вдоль оси значений: вверх по оси y или вдоль x на band-шкале y. У строки без значения точки нет. |
| `r` | `number \| string \| (d, index) => number` | `3` | Радиус точек в пунктах либо поле или функция, дающие каждой строке значение, которое `rScale` превращает в радиус (без шкалы значение и есть радиус). У строки, значение которой отсутствует, точки нет. |
| `rScale` | `{ type: "sqrt"; domain?: [number, number]; range: [number, number] }` | нет | Как значения `r` становятся радиусами: их квадратные корни на `domain` (от 0 до наибольшего значения, если домена нет) отображаются на `range` радиусов, так что при диапазоне от 0 площадь каждой точки пропорциональна её значению. Значения вне домена принимают крайние значения диапазона. |
| `stagger` | `number` | `0` | При появлении графика (appear) точки начинают расти одна за другой, строка за строкой, в течение этой доли времени (от 0 до 0.95); 0 заставляет их расти одновременно. |
| `inactiveOpacity` | `number` | нет | Прозрачность остальных точек, пока одна из них под указателем или выбрана клавишами: они плавно гаснут и возвращаются вместе с наведением. |
| `color` | `string \| (d, index) => string` | нет | Категория каждой точки, её цвет берётся из `colorScale` графика: `fill` точки там, где ничего другого не задано ([Цвета по категориям](#colors-by-category)). |
| `hitRadius` | `number` | `8` | На каком расстоянии за краем точки её всё ещё находит указатель: из точек в пределах этого расстояния указатель находит ту, чей центр ближе всего к нему (ячейки Вороного), так что маленький пузырь поверх большого находится вокруг собственного центра. Строка dot plot находится из любой точки вдоль неё, если радиус не меньше ширины строки. |
| `scale` | `"y" \| "y2"` | `"y"` | Шкала y, вдоль которой идут её значения ([Две оси](#dual-axes)); точки на band-шкале y располагают свои значения вдоль x. |
| `hoverDotClass`, `hoverDotStyle` | `string`, `StyleProp<ViewStyle>` | `dotClass`, `dotStyle` графика | Часть `hoverDot`: представление шириной и высотой с точку под указателем, центрированное на ней. `rounded-full outline-2 outline-offset-1` обводит её кольцом; `hidden` не показывает ничего для этой отметки. |

Точка заливается своим `fill` (`fill-*`; наследуется от графика так же, как наследует CSS), а там, где он не задан, — `currentColor` (цвет её категории с `color`). Обводка рисуется её `stroke`, если он задан (`stroke-*`, `strokeWidth`): кольцо цвета карточки отделяет перекрывающиеся точки друг от друга. Над графиком с точками график сообщает строку той точки, чей центр ближе всего к указателю (`null` от каждой точки). Клавиши перебирают строки в их порядке (отсортируйте строки по x, и клавиши пойдут по точкам слева направо), а часть `hoverDot` отмечает точку. При appear графика точки вырастают из ничего. С `motion.update` они переходят к новым значениям и размерам, точка новой строки вырастает, а точка исчезнувшей строки сжимается. Всё это выполняется аниматором платформы, без перерисовки отметок.

## Cell

<!-- demo: el-plot-cells -->

`Cell` рисует по прямоугольнику на строку сразу в двух полосах: в полосе её категории `x` на `band`-шкале x и в полосе её категории `y` на `band`-шкале y. Строки образуют сетку — тепловую карту (heatmap). Ячейками являются матрица корреляций, календарь месячной доходности и неделя сделок по часам. Каждая ячейка заполняет свои две полосы, поэтому `paddingInner` и `paddingOuter` шкал — это промежутки между ячейками и вокруг них. Её цвет — это её `value` вдоль `colorScale` графика, то есть градиента цветов ([Цвета по шкале-градиенту](#colors-along-a-ramp)), либо цвет её категории `color` через порядковую шкалу. Ячейки используют шкалы x и y графика.

```tsx
import { Cell, Plot, PlotAxis } from "nsh/base/plot";
import { token } from "nsh";

const months = [
  { year: "2025", month: "Jan", ret: 2.7 },
  { year: "2025", month: "Feb", ret: -1.4 },
  { year: "2025", month: "Mar", ret: -5.8 },
  { year: "2026", month: "Jan", ret: 1.9 },
  { year: "2026", month: "Feb", ret: 3.2 },
];

<Plot
  data={months}
  xScale={{ type: "band", domain: ["Jan", "Feb", "Mar"], paddingInner: 0.08 }}
  yScale={{ type: "band", paddingInner: 0.08 }}
  colorScale={{ type: "diverging", range: [token("destructive"), token("muted"), token("chart-1")] }}
  class="ml-10 mt-6 h-24"
>
  <Cell
    x="month"
    y="year"
    value="ret"
    cornerRadii={3}
    inactiveOpacity={0.4}
    label={(d) => d.ret.toFixed(1)}
    labelClass="text-xs tabular-nums text-black/70 data-dark:text-white"
    hoverCellClass="rounded-sm outline-2 outline-offset-1 outline-foreground"
  />
  <PlotAxis placement="top" labelClass="pb-1.5 text-xs text-muted-foreground" />
  <PlotAxis placement="left" labelClass="w-10 pr-2 text-right text-xs text-muted-foreground" />
</Plot>
```

`domain` band-шкалы перечисляет категории её полос по порядку, как это делает `scaleBand().domain()` в d3: месяцы календаря с января по декабрь, у каждого своя полоса независимо от того, пришли ли его строки, поэтому март 2026 выше — пустое место в сетке. Без домена полосами становятся категории, которые ячейки называют вдоль оси, в порядке, в котором их приносят строки (2025 выше 2026). `PlotAxis` на band-шкале категорий подписывает категорию каждой полосы, без `format`.

Матрица корреляций называет одни и те же активы по обеим осям, её `domain` — весь диапазон коэффициентов, поэтому коэффициент 0 занимает середину градиента, какие бы активы ни были в портфеле:

```tsx
const tickers = ["NVDA", "AMD", "MSFT", "GLD"];

<Plot
  data={pairs}
  xScale={{ type: "band", domain: tickers, paddingInner: 0.06 }}
  yScale={{ type: "band", domain: tickers, paddingInner: 0.06 }}
  colorScale={{ type: "diverging", domain: [-1, 1], range: [token("chart-2"), token("muted"), token("chart-1")] }}
  class="aspect-square"
>
  <Cell x="b" y="a" value="r" label={(d) => d.r.toFixed(2)} labelClass="text-xs text-black/70 data-dark:text-white" />
</Plot>
```

| Свойство | Тип | По умолчанию | Описание |
|---|---|---|---|
| `x` | `string \| (d, index) => string` | нет | Столбец каждой строки: поле (его текст или число) либо функция, возвращающая категорию. Ячейка строки попадает в полосу этой категории на шкале x; у строки без категории ячейки нет. |
| `y` | `string \| (d, index) => string` | нет | Ряд каждой строки, как `x` на шкале y. |
| `value` | `string \| (d, index) => number` | нет | Число каждой ячейки, из которого `sequential` или `diverging` `colorScale` делает её цвет. Строка без числа получает цвет `unknown` шкалы, а если его нет, у неё нет ячейки. |
| `color` | `string \| (d, index) => string` | нет | Категория каждой ячейки, её цвет берётся из `ordinal` `colorScale` ([Цвета по категориям](#colors-by-category)). |
| `cornerRadii` | `number \| { topLeft?, topRight?, bottomRight?, bottomLeft? }` | `0` | Радиус каждого угла ячеек. |
| `stagger` | `number` | `0` | При appear графика ячейки начинают расти одна за другой, строка за строкой, в течение этой доли времени (от 0 до 0.95), каждая из своей середины до конца; 0 заставляет их расти одновременно. |
| `inactiveOpacity` | `number` | нет | Прозрачность остальных ячеек и их подписей, пока одна из них под указателем или выбрана клавишами: они плавно гаснут и возвращаются вместе с наведением. |
| `label` | `string \| (d, index) => string` | нет | Часть `label`: текст в середине каждой ячейки, поле (его текст или число) либо функция, возвращающая текст, который форматирует приложение. Каждая подпись показывается там, где её рассчитанный размер помещается в ячейку, поэтому на узком графике видны одни цвета. |
| `labelClass`, `labelStyle` | `string`, `StyleProp<TextStyle>` | нет | Шрифт, размер и цвет подписей. Подпись на ячейке с тёмным цветом `colorScale` получает атрибут `data-dark` (где светлый текст читается лучше), поэтому `data-dark:text-white` делает его там светлым. |
| `hoverCellClass`, `hoverCellStyle` | `string`, `StyleProp<ViewStyle>` | нет | Часть `hoverCell`: представление размером с ячейку под указателем или выбранную клавишами, поверх неё; `rounded-sm outline-2 outline-foreground` обводит её кольцом. Оно движется за указателем в том же кадре и скользит на пружине `pointer`, когда его двигают клавиши. |

Ячейка заливается своим цветом из `colorScale` графика там, где приложение не задаёт `fill` (сначала применяются классы и стили `fill-*`, наследуемые от графика так же, как наследует CSS), а там, где ничего не задано, — `currentColor`; обводка рисуется её `stroke`, если он задан. Над графиком с ячейками график сообщает строку ячейки под указателем, а в промежутке между двумя ячейками — ближайшей из них. Стрелки перемещаются по сетке так, как она видна на экране ([Клавиатура](#keyboard)), а экранные дикторы идут по строкам в их порядке. При appear графика ячейки вырастают из своих середин. С `motion.update` цвет ячейки плавно переходит к новому, ячейка новой строки вырастает, ячейка исчезнувшей строки сжимается, а ячейки перемещаются в свои новые полосы. Всё это выполняется аниматором платформы, без перерисовки отметок.
<a id="pie-and-arc"></a>

## Pie и Arc

`Pie` делит полный оборот на секторы, по одному на строку, и каждому достаётся доля от значений строк. Секторы идут по часовой стрелке от 12 часов в порядке строк вокруг центра графика. `innerRadius` превращает его в кольцевую диаграмму. Каждый сектор — отдельный холст, часть `slice`, поэтому каждый рисуется со своими классами: `sliceClass` — это либо одни и те же классы для всех секторов, либо функция от строки сектора, которая даёт каждому свои. Над круговой диаграммой график сообщает строку сектора под указателем (в отверстии или за пределами кольца строки нет), его подсказка следует за указателем, а этот сектор вырастает на `hoverLift`, пока остальные затухают на `hoverDim`. Всё это выполняется на аниматоре платформы с `motion` графика. `Arc` рисует один сектор между двумя углами: трек и значение индикатора или кольцо позади круговой диаграммы.

<!-- demo: el-plot-pie -->

```tsx
import { signal, Text, View } from "nsh";
import { Pie, Plot } from "nsh/base/plot";

type Plan = { name: string; seats: number };

const FILLS = ["fill-chart-1", "fill-chart-2", "fill-chart-3", "fill-chart-4"];
const KEYS = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4"];

export function Seats(props: { plans: Plan[] }) {
  const hovered = signal<number | null>(null);
  const total = () => props.plans.reduce((sum, p) => sum + p.seats, 0);
  const shown = () => (hovered() == null ? undefined : props.plans[hovered()!]);
  const share = (n: number) => `${((n / total()) * 100).toFixed(1)}%`;
  return (
    <View class="flex-row items-center gap-6 rounded-xl border border-border bg-card p-4">
      <Plot
        data={props.plans}
        accessibilityLabel={`Seats by plan, ${total()} in all`}
        accessibilityValue={{ text: shown() && `${shown()!.name}, ${shown()!.seats} seats, ${share(shown()!.seats)}` }}
        appearGeneration={1}
        motion={{ appear: { duration: 900, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 400, damping: 32 } }}
        onHoverChange={({ index }) => hovered.set(index)}
        class="size-44"
      >
        {/* A donut with 2-point gaps (0.025 of a turn at its 80-point rim). */}
        <Pie
          value="seats"
          innerRadius="62%"
          padAngle={0.025}
          sliceClass={(_, i) => FILLS[i % FILLS.length]}
          hoverLift={6}
          hoverDim={0.64}
        />
        {/* The hovered plan, or the total, in the hole. */}
        <View class="absolute inset-0 items-center justify-center gap-1">
          <Text class="text-xs text-muted-foreground">{shown()?.name ?? "Total"}</Text>
          <Text class="text-2xl font-semibold tabular-nums text-foreground">{shown()?.seats ?? total()}</Text>
        </View>
      </Plot>
      {/* The legend: a key in each slice's color, the value and the share at the end. */}
      <View class="flex-1 gap-1">
        {props.plans.map((plan, i) => (
          <View class="flex-row items-center gap-3 rounded-md px-2 py-1.5" style={{ opacity: hovered() == null || hovered() === i ? 1 : 0.45 }}>
            <View class={`size-2 rounded-sm ${KEYS[i % KEYS.length]}`} />
            <Text class="flex-1 text-sm text-foreground">{plan.name}</Text>
            <Text class="text-right text-sm tabular-nums text-foreground">{plan.seats}</Text>
            <Text class="w-14 text-right text-sm tabular-nums text-muted-foreground">{share(plan.seats)}</Text>
          </View>
        ))}
      </View>
    </View>
  );
}
```

```rust
use nsh_base::plot::{SliceClass, pie, plot};
use nsh_core::{element::{RowAccessor, Rows}, prelude::*};

fn seats(seats: Vec<f64>) -> impl IntoElement {
    let rows = Rows::from_columns(seats.len(), [("seats".into(), seats)]);
    plot()
        .data(rows)
        .accessibility_label("Seats by plan")
        .w(px(176.0))
        .h(px(176.0))
        .child(
            pie()
                .value(RowAccessor::key("seats"))
                .inner_radius(relative(0.62))
                .slice_class(SliceClass::from_fn(|i| format!("fill-chart-{}", i % 4 + 1).into())),
        )
}
```

| Mark | Prop | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|---|
| `Pie` | `value` | `.value(..)` | `keyof T \| (d: T, index: number) => number` | `y` графика | Сколько стоит каждая строка. У строки, которая стоит ноль, меньше нуля или не имеет числа, сектора нет. |
| `Pie`, `Arc` | `startAngle`, `endAngle` | `.start_angle(..)`, `.end_angle(..)` | `number` (радианы) | `0`, `2 * Math.PI` | Где начинается первый сектор и где заканчивается последний (концы дуги): радианы от 12 часов по часовой стрелке. Если `endAngle` меньше `startAngle`, секторы круговой диаграммы раскладываются в обратную сторону. |
| `Pie` | `padAngle` | `.pad_angle(..)` | `number` (радианы) | `0` | Угол между соседними секторами у внешнего края. Каждый зазор сохраняет эту ширину (около `padAngle * outerRadius` пунктов) на любом радиусе, его края параллельны, так что в центре или у отверстия он так же широк, как у внешнего края. Сектор, слишком узкий, чтобы дойти до центра, заканчивается там, где сходятся его края. У круговой диаграммы из одного сектора зазора нет. |
| `Pie`, `Arc` | `innerRadius`, `outerRadius` | `.inner_radius(..)`, `.outer_radius(..)` | ``number \| `${number}%` `` | `0`, `"100%"` | Радиусы отверстия и внешнего края: в пунктах или в процентах от наибольшего круга, который вмещает график (половина его меньшей стороны). |
| `Pie`, `Arc` | `cornerRadius` | `.corner_radius(..)` | `number` | `0` | Радиус углов каждого сектора (и дуги) в пунктах, как у `arc.cornerRadius` в d3: каждый угол — окружность, касающаяся края и внешнего края или отверстия, включая параллельные края зазоров. Он не превышает половины толщины кольца, что даёт дуге круглые концы, а на секторе, слишком узком для двух целых углов, они встречаются посередине. У целого кольца остаются только круглые края. |
| `Pie` | `sliceClass` | `.slice_class(..)` | `string \| (d: T, index: number) => string` | нет | Классы каждого сектора (`fill-*`, `stroke-*`): одни и те же для всех секторов или функция от строки сектора и её индекса, вызываемая один раз для каждой строки при изменении строк или функции. |
| `Pie` | `sliceStyle` | `.slice_style(..)` | `StyleProp<ViewStyle>` | нет | Стиль каждого сектора поверх его классов (`fill`, `stroke`, `strokeWidth`). |
| `Pie` | `hoverLift` | `.hover_lift(..)` | `number` | `0` | Насколько сектор под указателем выступает над остальными, в пунктах: на пружине `pointer` графика, плавно появляясь и исчезая вместе с наведением. |
| `Pie` | `hoverDim` | `.hover_dim(..)` | `number` (от 0 до 1) | `0` | Насколько остальные секторы затухают, пока один под указателем, как доля их непрозрачности, с затуханием наведения. |
| `Pie` | `color` | `.color(..)` | `keyof T \| (d: T, index: number) => string` | нет | Категория каждого сектора, его цвет из `colorScale` графика: `fill` сектора там, где цвет ничем не задан ([Цвета по категориям](#colors-by-category)). |
| `Pie` | `label` | `.label(..)` | `string \| (d: T, index: number) => string` | нет | Текст рядом с каждым сектором: поле (его число) или функция, возвращающая текст, который форматирует приложение. У строки с пустым текстом и у сектора уже полуградуса текста нет. |
| `Pie` | `labelClass`, `labelStyle` | `.label_class(..)`, `.label_style(..)` | `string`, `StyleProp<TextStyle>` | нет | Часть `label`: текст снаружи кольца на конце его линии-выноски, центрированный по ней; на правой половине диаграммы он стоит после линии, на левой — перед ней. Его padding — это расстояние от конца линии. |
| `Pie` | `labelGap` | `.label_gap(..)` | `number` | `15` | Насколько далеко за внешним краем стоят подписи, в пунктах. |
| `Pie` | `labelLineClass`, `labelLineStyle` | `.label_line_class(..)`, `.label_line_style(..)` | `string \| (d: T, index: number) => string`, `StyleProp<ViewStyle>` | нет | Часть `labelLine`: линия-выноска каждой подписи, от внешнего края посередине сектора до радиуса подписи, затем поперёк к тексту. Её `stroke` (`currentColor`, если не задан), `strokeWidth` и `strokeDasharray` одинаковы для каждой линии или являются функцией от строки сектора; показывается, как только получает стиль. |

Сектор и дуга заливаются цветом `fill` (`currentColor`, если он не задан; цвет категории сектора с `color`) и обводят свои края цветом `stroke` (без обводки, если он не задан). Их собственные значения главнее значений круговой диаграммы и графика, наследуемых по правилам CSS: обводка цветом поверхности (`stroke-card stroke-2`) прорезает и зазоры между секторами. Их значения не участвуют в шкалах графика. Секторы круговой диаграммы перерисовываются только при изменении строк, их значений, углов, радиусов или размера графика; подъём и затухание при движении указателя выполняются на аниматоре.

Графики с круговой диаграммой обычно размещают подсказку у указателя (`tooltipPlacement="pointer"`), поскольку у строк круговой диаграммы нет места вдоль оси: перекрестие и подсказка следуют за указателем над ней, а точки при наведении не показываются. Центр кольцевой диаграммы — это собственный элемент приложения, наложенный на график (значение сектора под указателем или итог), как и её легенда, где каждый ключ окрашен в цвет своего сектора.

Подписи круговой диаграммы называют её секторы снаружи кольца: каждый текст стоит на расстоянии `labelGap` за внешним краем в направлении середины своего сектора (центроид сектора на этом радиусе), затем сдвигается поперёк до этого радиуса на стороне диаграммы, где находится сектор, а линия-выноска с заданным стилем соединяет его с внешним краем. Подписи разворачиваются вместе с секторами при появлении графика и проявляются к его концу, двигаются вместе с ними при `motion.update` и поднимаются вместе с сектором под указателем; всё это выполняется на аниматоре платформы. Оставьте графику место для них: `outerRadius` меньше `"100%"` или свободное место вокруг графика. Собственное содержимое приложения у сектора — это элемент, наложенный на график, как и центр кольцевой диаграммы.

```tsx
<Pie
  value="seats"
  outerRadius="64%"
  sliceClass={(_, i) => FILLS[i % FILLS.length]}
  label={(d) => share(d.seats)}
  labelGap={12}
  labelClass="px-1 text-xs tabular-nums text-muted-foreground"
  labelLineClass="stroke-border"
/>
```

<a id="colors-by-category"></a>

## Цвета по категориям

`colorScale` графика задаёт цвета категорий, как это делает `scaleOrdinal` в d3, а `color` метки называет категорию каждой строки: это поле (его текст или число) или функция от строки. Категории из `domain` получают цвета из `range` по порядку, а если домен длиннее, диапазон начинается снова с первого цвета. Категория вне домена получает `unknown`; если его нет, она входит в домен при первой встрече с меткой (метки по порядку, у каждой строки по порядку) и получает следующий цвет, поэтому без `domain` вообще категории получают цвета в том порядке, в каком их приносят строки. Цвета задаёт приложение: это CSS-цвета, в том числе токены темы.

```tsx
import { Pie, Plot } from "nsh/base/plot";
import { token } from "nsh";

const regions = [
  { name: "North America", revenue: 482 },
  { name: "EMEA", revenue: 312 },
  { name: "APAC", revenue: 268 },
];

<Plot data={regions} colorScale={{ type: "ordinal", range: [token("chart-1"), token("chart-2"), token("chart-3")] }} class="size-44">
  <Pie value="revenue" color="name" innerRadius="62%" />
</Plot>
```

Сектор, столбец и точка получают цвет своей строки; `Line` и `Area` — это по одной серии и получают цвет своей первой строки, так что линия из собственных строк (`data`) окрашена в цвет своих строк, а одна шкала сохраняет цвет категории одинаковым на круговой диаграмме, столбцах и линиях. Этот цвет — `fill` метки (`stroke` линии), если ничто другое его не задаёт, на месте `currentColor`: сначала идут классы и стили приложения, затем стили части (`sliceClass`, `sliceStyle`), метки и те, что она наследует от графика, так что `sliceClass={(d) => (d.name === "Other" ? "fill-muted" : "")}` выделяет один сектор, пока шкала окрашивает остальные. Рядом с частью `background` `fill` столбца и области остаётся `none`.

| `OrdinalScale` | Тип | По умолчанию | Описание |
|---|---|---|---|
| `type` | `"ordinal"` | (обязательно) | Вид шкалы. |
| `domain` | `string[]` | нет | Категории по порядку. Категория, записанная дважды, сохраняет своё первое место. |
| `range` | `string[]` | (обязательно) | Цвета по порядку (CSS-цвета); категория `i` получает цвет `i % range.length`. |
| `unknown` | `string` | нет | Цвет каждой категории вне домена; если его нет, каждая получает следующий цвет. |

<a id="colors-along-a-ramp"></a>

### Цвета вдоль градиента

`colorScale` вида `type: "sequential"` или `"diverging"` задаёт числам цвета вдоль градиента, как это делают `scaleSequential` и `scaleDiverging` в d3, для `value` в [`Cell`](#cell). Его `range` — это два или больше цветов, равномерно расставленных вдоль градиента от нижнего края к верхнему и смешиваемых между собой в OKLab: шаги выглядят равномерными для глаза, а два оттенка встречаются через собственную смесь. Значение за пределами `domain` получает цвет края градиента, а строка без значения получает `unknown`.

`sequential` растягивает свой `domain` на весь градиент, а домен — это протяжённость значений ячеек, если приложение не задаёт его само: счётчики, объёмы, активность. `diverging` помещает свой `pivot` (по умолчанию 0) на средний цвет градиента: значения ниже него идут вдоль первой половины, значения выше — вдоль второй. Убытки одним оттенком, прибыль другим, ровный месяц — в нейтральной середине. Если приложение не задаёт домен, он равен протяжённости значений, выровненной по обе стороны от опорной точки (`symmetric` в Observable Plot), так что убыток и прибыль одинакового размера одинаково выразительны; матрица корреляций задаёт `domain: [-1, 1]`.

```tsx
colorScale={{ type: "sequential", range: [token("muted"), token("chart-1")] }}
colorScale={{ type: "diverging", pivot: 0, range: [token("destructive"), token("muted"), token("chart-1")] }}
```

| `SequentialScale`, `DivergingScale` | Тип | По умолчанию | Описание |
|---|---|---|---|
| `type` | `"sequential" \| "diverging"` | (обязательно) | Вид градиента. |
| `domain` | `[number, number]` | протяжённость значений (для `diverging` выровненная по опорной точке) | Значения двух концов градиента. |
| `pivot` | `number` | `0` | Только для `diverging`: значение среднего цвета градиента. |
| `range` | `string[]` | (обязательно) | Цвета вдоль градиента от нижнего края к верхнему (CSS-цвета), смешиваемые между собой в OKLab. |
| `unknown` | `string` | нет | Цвет строки без значения; если его нет, у такой строки нет ячейки. |
<a id="radialline-and-radialgrid"></a>

## RadialLine и RadialGrid

`RadialLine` рисует ряд вокруг центра графика: многоугольник радара или линию по кругу циферблата. Каждая строка со значением — это точка под углом этой строки, удалённая от центра настолько, насколько шкала y графика размещает её значение: от `innerRadius` (наименьшее значение домена) до `outerRadius` (наибольшее). Без `angle` строки идут равномерно по кругу от 12 часов по часовой стрелке, по порядку, так что шесть строк образуют шестиугольник; `closed` соединяет последнюю точку с первой. Линия обводит свои точки и заливает область вниз до `innerRadius` (по умолчанию до центра), так что одна метка — это и контур радара, и его заливка. `RadialGrid` рисует за ним сетку радара: `rings` на значениях шкалы y, многоугольники через углы строк или `circular`, и `spokes` к каждой строке. Над радиальной линией график сообщает строку, чей угол ближе всего к углу указателя в пределах внешнего радиуса линии, а точка наведения каждой линии находится на её точке этой строки.

```tsx
import { signal, Text, View } from "nsh";
import { Plot, RadialGrid, RadialLine } from "nsh/base/plot";

type Metric = { name: string; ours: number; theirs: number };

export function Compare(props: { metrics: Metric[] }) {
  const hovered = signal<number | null>(null);
  const shown = () => (hovered() == null ? undefined : props.metrics[hovered()!]);
  return (
    <View class="items-center gap-3 rounded-xl border border-border bg-card p-4">
      <Text class="text-sm tabular-nums text-muted-foreground">
        {shown() ? `${shown()!.name}: ${shown()!.ours} against ${shown()!.theirs}` : "Ours against theirs"}
      </Text>
      <Plot
        data={props.metrics}
        yScale={{ type: "linear", domain: [0, 100] }}
        accessibilityLabel="Ours against theirs on six metrics"
        accessibilityValue={{ text: shown() && `${shown()!.name}: ours ${shown()!.ours}, theirs ${shown()!.theirs}` }}
        appearGeneration={1}
        motion={{ appear: { duration: 700, easing: "easeOut" }, update: { duration: 400, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 380, damping: 34 } }}
        onHoverChange={({ index }) => hovered.set(index)}
        class="size-56"
      >
        <RadialGrid rings={[25, 50, 75, 100]} spokes outerRadius="80%" class="stroke-border" />
        <RadialLine radius="theirs" closed outerRadius="80%" class="fill-chart-2/15 stroke-chart-2" hoverDotClass="size-2 rounded-full border-2 border-card bg-chart-2" />
        <RadialLine radius="ours" closed outerRadius="80%" class="fill-chart-1/25 stroke-chart-1 stroke-2" hoverDotClass="size-2.5 rounded-full border-2 border-card bg-chart-1" />
        {/* Each metric's name past the rim at its row's angle, growing away from the center. */}
        {props.metrics.map((m, i) => {
          const angle = (2 * Math.PI * i) / props.metrics.length;
          const away = (v: number) => (v > 0.01 ? "flex-start" : v < -0.01 ? "flex-end" : "center");
          const align = (v: number) => (v > 0.01 ? "left" : v < -0.01 ? "right" : "center");
          return (
            <View
              class="absolute size-0"
              style={{
                left: `${50 + 44 * Math.sin(angle)}%`,
                top: `${50 - 44 * Math.cos(angle)}%`,
                alignItems: away(Math.sin(angle)),
                justifyContent: away(-Math.cos(angle)),
                pointerEvents: "none",
              }}
            >
              <Text class="w-24 text-xs text-muted-foreground" style={{ textAlign: align(Math.sin(angle)) }}>{m.name}</Text>
            </View>
          );
        })}
      </Plot>
    </View>
  );
}
```

| Метка | Свойство | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|---|
| `RadialLine` | `radius` | `.radius(..)` | `keyof T \| (d: T, index: number) => number` | `y` графика | Что определяет удаление каждой строки от центра: значение шкалы y, от `innerRadius` для наименьшего значения домена до `outerRadius` для наибольшего. Строка без него пропускается. |
| `RadialLine` | `angle` | `.angle(..)` | `keyof T \| (d: T, index: number) => number` | строки равномерно по кругу | Где каждая строка находится вокруг центра: радианы от 12 часов по часовой стрелке. |
| `RadialLine`, `RadialGrid` | `startAngle`, `endAngle` | `.start_angle(..)`, `.end_angle(..)` | `number` (радианы) | `0`, `2 * Math.PI` | Где находятся строки без `angle`: от `startAngle`, равномерно. По полному кругу они отстоят друг от друга на шаг в `1 / rows` круга, последняя — за шаг до первой; по части круга первая лежит на `startAngle`, а последняя на `endAngle`. |
| `RadialLine`, `RadialGrid` | `innerRadius`, `outerRadius` | `.inner_radius(..)`, `.outer_radius(..)` | ``number \| `${number}%` `` | `0`, `"100%"` | Куда попадают наименьшее и наибольшее значения домена y: в пунктах или в процентах от самого большого круга, который вмещает график (половина его меньшей стороны), как радиусы круговой диаграммы. Область линии заливается вниз до `innerRadius`; спицы сетки идут между этими двумя значениями. |
| `RadialLine` | `curve` | `.curve(..)` | `"linear" \| "natural" \| "stepAfter"` | `"linear"` | Как она соединяет свои точки: прямыми отрезками, плавной кривой (в замкнутом виде — плавной петлёй) или ступенями. |
| `RadialLine` | `closed` | `.closed(..)` | `boolean` | `false` | Продолжается от последней точки обратно к первой: многоугольник радара. В разомкнутом виде её область замыкается к центру (или вдоль своего внутреннего края). |
| `RadialLine` | `dot`, `dotClass`, `dotStyle` | `.dot(..)`, `.dot_class(..)`, `.dot_style(..)` | `boolean`, `string`, `StyleProp<ViewStyle>` | `false` | Точка на каждой вершине: view, центрированный на ней; его размер, фон и граница задаются приложением. Точки вырастают и движутся вместе с линией. |
| `RadialLine` | `backgroundClass`, `backgroundStyle` | `.background_class(..)`, `.background_style(..)` | `string`, `StyleProp<ViewStyle>` | нет | Её фон: блок вокруг её внешнего круга, чей фон (`bg-*`, градиент) виден только внутри её области, под её `fill`, как у области. |
| `RadialLine` | `hoverDotClass`, `hoverDotStyle` | `.hover_dot_class(..)`, `.hover_dot_style(..)` | `string`, `StyleProp<ViewStyle>` | `dotClass`, `dotStyle` графика | Её точка наведения на её точке той строки, что под указателем; указатель находится в том же кадре и плавно переходит к следующей строке на пружине `pointer` графика, когда клавиши переключают строки; `hidden` не показывает ничего. |
| `RadialGrid` | `rings` | `.rings(..)` | `number[]` | нет | Где находятся её кольца, как значения шкалы y: каждое на том удалении, на которое радиальная линия помещает это значение. |
| `RadialGrid` | `spokes` | `.spokes(..)` | `boolean` | `false` | Линия от `innerRadius` до `outerRadius` под углом каждой строки. |
| `RadialGrid` | `circular` | `.circular(..)` | `boolean` | `false` | Её кольца — окружности (дуги по части круга). По умолчанию это многоугольники через углы строк, а пока в графике меньше трёх строк — окружности. |

Радиальная линия обводится с помощью `stroke`, `strokeWidth` и `strokeDasharray` (`currentColor`, шириной 1 и сплошная, если ничего не задано) и заливает свою область с помощью `fill` (без заливки, если ничего не задано); радиальная сетка обводится так же, как обычная сетка. Их оформление наследуется от графика. Значения линии вместе с 0 образуют домен шкалы y, если у графика его нет, так что радар из положительных значений растёт от своего центра; кольца сетки в домен не входят. Задайте графику y `domain`, чтобы кольца и шкала радара оставались неизменными, пока меняются значения.

Подписи вокруг радара — это элементы приложения, наложенные на график, как центр кольцевой диаграммы. Без `angle` строка `i` из `n` находится на `a = 2π·i / n` от 12 часов, так что в квадратном графике блок без размера в `left: 50% + r·sin(a)`, `top: 50% − r·cos(a)` (`r` — процент от стороны графика, немного больше половины доли внешнего радиуса) отмечает место за краем, а название растёт от него в сторону от центра: справа от него в правой половине, слева от него в левой, над ним вверху и под ним внизу, как выше.

```rust
use nsh_base::plot::{plot, radial_grid, radial_line};
use nsh_core::{element::{RowAccessor, Rows}, prelude::*};

fn compare(ours: Vec<f64>, theirs: Vec<f64>) -> impl IntoElement {
    let rows = Rows::from_columns(ours.len(), [("ours".into(), ours), ("theirs".into(), theirs)]);
    plot()
        .data(rows)
        .accessibility_label("Ours against theirs")
        .w(px(224.0))
        .h(px(224.0))
        .child(radial_grid().rings(vec![25.0, 50.0, 75.0, 100.0]).spokes(true))
        .child(radial_line().radius(RowAccessor::key("theirs")).closed(true))
        .child(radial_line().radius(RowAccessor::key("ours")).closed(true))
}
```

## Sankey

`Sankey` рисует потоки между строками графика: каждая строка — это узел, а `links` называет потоки между ними, `{
source, target, value }` with the indices of two rows in the plot's `data`. Он раскладывает их так, как это делает d3-sankey, в размере графика: узлы по столбцам (`nodeAlign`), каждый высотой в наибольший из втекающих в него или вытекающих из него потоков, узлы столбца на расстоянии `nodePadding` друг от друга, подтянутые к своим потокам за `iterations` проходов; и лента для каждой связи от правого края её источника до левого края её цели, на каждом конце такой ширины, какова её доля стороны этого узла, так что обе стороны каждого узла покрыты, даже если его входящие и исходящие потоки различаются. Каждый столбец центрируется в графике, а ряд одинаковых столбцов из одного узла слегка смещается, чтобы ленты между ними изгибались. `valueScale="sqrt"` задаёт размеры по квадратному корню из значений, так что один доминирующий поток не подавляет малые.

Каждый узел и каждая лента — это отдельный canvas, части `node` и `link`, так что каждый рисуется со своими классами: `nodeClass` — это одни и те же классы для каждого узла или функция строки узла, а `linkClass` — те же для каждой ленты или функция связи. Часть `linkBackground` показывает фон внутри каждой ленты, блок от её источника до её цели: функция связи даёт каждой ленте градиент от цвета её источника к цвету её цели. Ленты рисуются под узлами, и те и другие прорисовываются от передней кромки вместе с появлением графика.

Над диаграммой sankey график сообщает узел под указателем (`onHoverChange`, строка узла; над лентой ничего), его подсказка следует за указателем, а связи, не присоединённые к этому узлу, затухают на `hoverDim` на аниматоре платформы с `motion` графика. Клавиши переключают узлы в порядке строк, подсказка располагается рядом с серединой каждого узла. `nodeLabel` задаёт название каждого узла, а `valueLabel` — его значение, значение идёт над названием, если заданы оба: слева от узла в первом столбце, справа от узла в последнем, по центру над узлом между ними, как размещает их диаграмма `gpui-component`; подпись рядом с узлом остаётся между верхом и низом графика, а подпись за краем графика помещается в то место, которое приложение оставляет вокруг него (его поле). Отступ части `nodeLabel` — это промежуток до её узла, а её классы задают её текст (`max-w-20 truncate` обрезает длинное название); подписи появляются плавно вместе с появлением графика. Когда приложение меняет потоки, каждый узел и каждая лента перемещается со старого места на новое с `motion.update` графика, и подпись вместе с ним: узел, новый для потоков, вырастает из своей середины, новая связь — из своей наименьшей ширины, а исчезнувшие уходят сразу; изменение во время движения продолжается с того места, где они нарисованы. Связи различаются по источнику и цели, поэтому перестановка `links` ничего не сдвигает.

```tsx
import { signal } from "nsh";
import { Plot, Sankey } from "nsh/base/plot";

type Step = { name: string };

const steps: Step[] = [{ name: "Visits" }, { name: "Sign-ups" }, { name: "Bounced" }, { name: "Trials" }, { name: "Paid" }, { name: "Churned" }];
const flows = [
  { source: 0, target: 1, value: 4200 },
  { source: 0, target: 2, value: 5800 },
  { source: 1, target: 3, value: 2600 },
  { source: 3, target: 4, value: 1100 },
  { source: 3, target: 5, value: 1500 },
];
// Each step's color: its node's fill, and its ribbons' gradient ends.
const FILLS = ["fill-chart-1", "fill-chart-2", "fill-muted", "fill-chart-3", "fill-chart-4", "fill-muted"];
const FROM = ["from-chart-1/40", "from-chart-2/40", "from-muted/40", "from-chart-3/40", "from-chart-4/40", "from-muted/40"];
const TO = ["to-chart-1/40", "to-chart-2/40", "to-muted/40", "to-chart-3/40", "to-chart-4/40", "to-muted/40"];

export function Funnel() {
  const hovered = signal<number | null>(null);
  return (
    <Plot
      data={steps}
      accessibilityLabel="Visits to paid: 10,000 visits, 1,100 paid"
      accessibilityValue={{ text: hovered() == null ? undefined : steps[hovered()!].name }}
      onHoverChange={({ index }) => hovered.set(index)}
      appearGeneration={1}
      motion={{ appear: { duration: 900, easing: "easeOut" }, update: { duration: 500 }, enter: { duration: 120 }, exit: { duration: 160 } }}
      class="mx-20 h-64"
    >
      <Sankey
        links={flows}
        nodeWidth={8}
        nodePadding={16}
        nodeCornerRadius={2}
        nodeClass={(_, i) => FILLS[i]}
        linkBackgroundClass={(link) => `bg-linear-to-r ${FROM[link.source]} ${TO[link.target]}`}
        hoverDim={0.7}
        nodeLabel={(d: Step) => d.name}
        valueLabel={(_: Step, value: number) => value.toLocaleString("en-US")}
        nodeLabelClass="px-2 py-1 text-xs text-muted-foreground"
        valueLabelClass="font-medium text-foreground"
      />
    </Plot>
  );
}
```

```rust
use nsh_base::plot::{RowClass, SankeyLinkClass, SankeyLinks, plot, sankey};
use nsh_core::{element::Rows, prelude::*};

fn funnel() -> impl IntoElement {
    plot()
        .data(Rows::from_columns(4, []))
        .accessibility_label("Visits to paid")
        .h(px(256.0))
        .child(
            sankey()
                .links(SankeyLinks::new([(0, 1, 4200.0), (1, 2, 2600.0), (2, 3, 1100.0)]))
                .node_width(px(8.0))
                .node_class(RowClass::from_fn(|i| format!("fill-chart-{}", i + 1).into()))
                .link_class(SankeyLinkClass::from("fill-foreground/10")),
        )
}
```

| Свойство | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
| `links` | `.links(..)` | `SankeyLink[]` (`{ source: number; target: number; value: number }`) | обязательно | Потоки: от строки `source` к строке `target` (их индексы в `data` графика), `value` — размер. Значение, которое не число или меньше нуля, ничего не несёт и ленту не рисует. |
| `nodeWidth` | `.node_width(..)` | `number` | `24` | Ширина каждого узла в пунктах. |
| `nodePadding` | `.node_padding(..)` | `number` | `8` | Промежуток между узлами столбца в пунктах; меньше в столбце, слишком тесном для него. |
| `nodeAlign` | `.node_align(..)` | `"justify" \| "left" \| "right" \| "center"` | `"justify"` | В каком столбце находится каждый узел, как у d3-sankey: его глубина от первого столбца (`"left"`), как можно правее, насколько позволяют его потоки (`"right"`), его глубина, причём узел, который только начинает потоки, ставится рядом со своими целями (`"center"`), или его глубина, причём узлы, в которых потоки заканчиваются, помещаются в последний столбец (`"justify"`). |
| `iterations` | `.iterations(..)` | `number` | `6` | Сколько проходов подтягивают узлы к их потокам. |
| `valueScale` | `.value_scale(..)` | `"linear" \| "sqrt"` | `"linear"` | Высоты и ширины пропорционально значениям или их квадратным корням. |
| `minLinkWidth` | `.min_link_width(..)` | `number` | `1` | Наименьшая толщина, с которой рисуется лента, в пунктах, чтобы малый поток оставался видимым. |
| `nodeCornerRadius` | `.node_corner_radius(..)` | `number` | `0` | Радиус углов каждого узла в пунктах. |
| `nodeClass` | `.node_class(..)` | `string \| (d: T, index: number) => string` | нет | Классы каждого узла (`fill-*`, `stroke-*`): одни и те же для каждого узла или функция строки узла и его индекса, вызываемая один раз для строки при изменении строк или функции. |
| `nodeStyle` | `.node_style(..)` | `StyleProp<ViewStyle>` | нет | Стиль каждого узла поверх его классов. |
| `linkClass` | `.link_class(..)` | `string \| (link: SankeyLink, index: number) => string` | нет | Классы каждой ленты: одни и те же для каждой ленты или функция связи и её индекса в `links`. |
| `linkStyle` | `.link_style(..)` | `StyleProp<ViewStyle>` | нет | Стиль каждой ленты поверх её классов. |
| `linkBackgroundClass` | `.link_background_class(..)` | `string \| (link: SankeyLink, index: number) => string` | нет | Классы фона каждой ленты (`bg-*`, градиент: `bg-linear-to-r from-… to-…`), показываемого внутри ленты поверх блока от её источника до её цели, под её `fill`. При наличии фона `fill` ленты равен `none`, если ничего не задано. |
| `linkBackgroundStyle` | `.link_background_style(..)` | `StyleProp<ViewStyle>` | нет | Стиль фона каждой ленты поверх его классов. |
| `hoverDim` | `.hover_dim(..)` | `number` (от 0 до 1) | `0` | Насколько затухают связи, не присоединённые к узлу под указателем, как доля их непрозрачности, на пружине `pointer` графика и вместе с затуханием при наведении. |
| `nodeLabel` | `.node_label(..)` | `keyof T \| (d: T, index: number) => string` | нет | Название рядом с каждым узлом: поле или функция строки узла и его индекса. |
| `valueLabel` | `.value_label(..)` | `(d: T, value: number, index: number) => string` | нет | Значение рядом с каждым узлом, над его названием: функция его строки и наибольшего из втекающих в него или вытекающих из него потоков (в значениях связей, независимо от `valueScale`). |
| `nodeLabelClass` | `.node_label_class(..)` | `string` | нет | Классы каждой подписи, части `nodeLabel`: её отступ — промежуток до её узла, её стили текста действуют на обе строки. |
| `nodeLabelStyle` | `.node_label_style(..)` | `StyleProp<TextStyle>` | нет | Стиль каждой подписи поверх её классов. |
| `valueLabelClass` | `.value_label_class(..)` | `string` | нет | Классы строки значения, части `valueLabel`, поверх классов подписи. |
| `valueLabelStyle` | `.value_label_style(..)` | `StyleProp<TextStyle>` | нет | Стиль строки значения поверх её классов. |

Узел и лента заливаются с помощью `fill` (`currentColor`, если ничего не задано) и обводятся с помощью `stroke` (без обводки, если ничего не задано), причём их собственные значения имеют приоритет над значениями sankey и графика, как их наследует CSS. Узел, через который ничего не проходит, имеет высоту в одну точку. Раскладка выполняется заново только при изменении связей, параметров или размера графика, но никогда не из-за указателя или появления; изменение размера графика ничего не сдвигает. Связи, образующие цикл или называющие строку, которой нет в графике, ничего не рисуют и записывают предупреждение в журнал. Их значения не участвуют в шкалах графика.
## Treemap

`Treemap` рисует строки графика как иерархию вложенных прямоугольников: каждая строка — это узел, её `id` и тот `parent`, который он называет, образуют дерево (`stratify` из d3), площадь каждого листа пропорциональна его `value`, а каждая группа — это рамка вокруг всего, что под ней, и её значение равно сумме значений её листьев (что бы ни хранила её собственная строка). Строка без `parent` или строка, чей родитель не указывает ни на одну строку, является узлом верхнего уровня; без `id` идентификатором строки служит её индекс, поэтому `parent` тоже может указывать на строку по индексу. Дерево во вложенной форме, которую пишет gpt-vis (`{ name, value, children }`), приложение превращает в строки несколькими строками кода; число уровней не ограничено.

Плитки строятся по алгоритму squarify, как в `treemapSquarify` из d3: узлы каждого уровня идут по убыванию значения, самые большие — от верхнего левого угла (`sort="value"`), с пропорциями, близкими к `ratio` (золотое сечение); `sort="data"` сохраняет порядок строк, поэтому узлы остаются на своих местах при изменении значений. `paddingInner` — зазор между соседними узлами, не проходящий по внешнему краю их родителя; `paddingOuter` — отступ внутри группы вокруг её дочерних узлов; `paddingTop` — полоса в верхней части каждой группы, где пишется её `groupLabel`. Каждый параметр принимает одно число для всех уровней или по одному числу на уровень, начиная с верхнего (`[3, 1]`: 3 между узлами верхнего уровня, 1 ниже них). Полоса уровня либо есть у всех соседних узлов одного родителя, либо нет ни у одного; она опускается, если у какого-то из них для неё слишком мало места, чтобы группы одного уровня выглядели одинаково.

Лист, значение которого равно 0, меньше 0 или не является числом, не занимает площади: он не рисуется, у него нет подписи, и указатель его не находит; то же верно для группы, все листья которой такие. Площади treemap — это доли целого, поэтому приложение само словами сообщает, что исключено (примечание под картой). Отрицательные числа предназначены для цвета: например, изменение за день на карте рынка.

Цвет узла берётся из его строки по `colorScale` графика: `colorValue` — число на цветовой шкале (`sequential`, `diverging`: убытки красные, прибыль зелёная, нулевое значение нейтральное, в пределах диапазона значений, если не задан `domain`), `color` — категория (`ordinal`: `"parent"` окрашивает листья группы одинаково). Листья — это часть `leaf` (`leafClass`, `leafStyle`), а группы — часть `group` (`groupClass`, `groupStyle`), которая рисуется только при наличии класса или стиля: это прямоугольник под дочерними узлами, видимый в зазорах, в отступе и в полосе. Каждая часть одинакова для всех узлов или является функцией строки узла. `cornerRadii` скругляет каждый прямоугольник.

`label` пишет имя каждого листа, а `valueLabel` — его значение под именем; оба — функции строки и её значения (у группы значение равно сумме её листьев): в левом верхнем углу (`labelPlacement="start"`) или по центру (`"center"`). Часть `label` — это блок размером с лист: его padding задаёт отступ от краёв листа, а его стили текста применяются к строкам. Имя обрезается многоточием там, где лист уже, и показывается, только если помещается; значение показывается целиком или не показывается вовсе. У подписи, лист которой имеет тёмный цвет, есть атрибут `data-dark`, поэтому `data-dark:text-white` сохраняет её читаемой на насыщенном красном.

Над treemap график сообщает об узле под указателем (`onHoverChange`, строка узла): это лист или группа в своей полосе или отступе; в зазоре между соседями — один из них, чтобы наведение не мерцало. Остальные затухают до `inactiveOpacity` (лист сохраняет свои группы, группа — всё, что под ней), а часть `hover` — представление размером с узел — обводит его. Клавиши со стрелками переходят к соседнему на экране листу, Home и End — к первому и последнему. При появлении графика узлы верхнего уровня вырастают из своих центров один за другим с `stagger`; когда строки меняются, каждый узел перемещается в новый прямоугольник и меняет цвет с `motion.update`, опознаваясь по `id`: новый узел вырастает из своего центра, а исчезнувший сжимается к нему. Шкалы x и y графика не используются.

<!-- demo: el-plot-treemap -->

```tsx
import { signal } from "nsh";
import { Plot, Treemap } from "nsh/base/plot";

type Row = { id: string; parent?: string; name: string; cap?: number; chg?: number };

const rows: Row[] = [
  { id: "tech", name: "Technology" },
  { id: "fin", name: "Financials" },
  { id: "NVDA", parent: "tech", name: "NVIDIA", cap: 4300, chg: 2.4 },
  { id: "MSFT", parent: "tech", name: "Microsoft", cap: 3700, chg: -0.6 },
  { id: "AVGO", parent: "tech", name: "Broadcom", cap: 1600, chg: 3.1 },
  { id: "JPM", parent: "fin", name: "JPMorgan", cap: 830, chg: 1.3 },
  { id: "V", parent: "fin", name: "Visa", cap: 670, chg: -0.3 },
];

export function MarketMap() {
  const hovered = signal<number | null>(null);
  return (
    <Plot
      data={rows}
      colorScale={{ type: "diverging", domain: [-3, 3], range: ["#dc2626", "#e5e5e5", "#16a34a"] }}
      accessibilityLabel="Market map of 5 stocks in 2 sectors"
      accessibilityValue={{ text: hovered() == null ? undefined : rows[hovered()!].name }}
      onHoverChange={({ index }) => hovered.set(index)}
      appearGeneration={1}
      motion={{ appear: { duration: 800 }, update: { duration: 500 }, enter: { duration: 120 }, exit: { duration: 160 } }}
      class="h-72"
    >
      <Treemap
        id="id"
        parent="parent"
        value="cap"
        colorValue="chg"
        paddingInner={[3, 1]}
        paddingTop={18}
        cornerRadii={2}
        inactiveOpacity={0.35}
        label="id"
        valueLabel={(d: Row) => `${d.chg! > 0 ? "+" : ""}${d.chg}%`}
        labelPlacement="center"
        labelClass="p-1 text-xs font-semibold text-foreground data-dark:text-white"
        groupLabel="name"
        groupLabelClass="px-1 text-xs text-muted-foreground"
        hoverClass="rounded-xs outline-2 outline-foreground"
      />
    </Plot>
  );
}
```

```rust
use nsh_base::plot::{ColorScale, DivergingScale, TreemapPadding, plot, treemap};
use nsh_core::{element::{RowAccessor, RowText, Rows}, prelude::*};

const IDS: [&str; 5] = ["tech", "fin", "NVDA", "MSFT", "JPM"];
const PARENTS: [&str; 5] = ["", "", "tech", "tech", "fin"];

fn market_map() -> impl IntoElement {
    let rows = Rows::from_columns(
        5,
        [
            ("cap".into(), vec![0.0, 0.0, 4300.0, 3700.0, 830.0]),
            ("chg".into(), vec![0.0, 0.0, 2.4, -0.6, 1.3]),
        ],
    );
    plot()
        .data(rows)
        .color_scale(ColorScale::from(
            DivergingScale::new([rgb(0xdc2626), rgb(0xe5e5e5), rgb(0x16a34a)]).domain([-3.0, 3.0]),
        ))
        .accessibility_label("Market map")
        .h(px(288.0))
        .child(
            treemap()
                .id(RowText::from_fn(|i| IDS[i].into()))
                .parent(RowText::from_fn(|i| PARENTS[i].into()))
                .value(RowAccessor::key("cap"))
                .color_value(RowAccessor::key("chg"))
                .padding_inner(TreemapPadding::from([px(3.0), px(1.0)]))
                .padding_top(TreemapPadding::from(px(18.0)))
                .label(RowText::from_fn(|i| IDS[i].into())),
        )
}
```

| Проп | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
| `id` | `.id(..)` | `keyof T \| (d: T, index: number) => string` | индекс строки | Идентификатор узла каждой строки: на него указывает `parent`, и по нему узел опознаётся при смене строк. |
| `parent` | `.parent(..)` | `keyof T \| (d: T, index: number) => string` | нет: каждая строка — узел верхнего уровня | Идентификатор родителя каждой строки; пустой или не совпадающий с идентификатором ни одной строки — для узла верхнего уровня. |
| `value` | `.value(..)` | `keyof T \| (d: T, index: number) => number` | обязательно | Размер каждого листа; у группы он равен сумме размеров её дочерних узлов. Лист со значением 0, меньше 0 или не числом не занимает площади. |
| `sort` | `.sort(..)` | `"value" \| "data"` | `"value"` | Соседние узлы: от самых больших, начиная с левого верхнего угла, или в порядке строк. |
| `ratio` | `.ratio(..)` | `number` | золотое сечение | Соотношение сторон, к которому стремятся плитки, не менее 1. |
| `paddingInner` | `.padding_inner(..)` | `number \| number[]` | `0` | Зазор между соседними узлами в пунктах, по значению на уровень, начиная с верхнего (последнее — для всех уровней ниже). |
| `paddingOuter` | `.padding_outer(..)` | `number \| number[]` | `0` | Отступ внутри каждой группы вокруг её дочерних узлов, по уровням. |
| `paddingTop` | `.padding_top(..)` | `number \| number[]` | `0` | Полоса в верхней части каждой группы для её `groupLabel`, по уровням; у всех соседних узлов одного родителя или ни у одного. |
| `cornerRadii` | `.corner_radii(..)` | `number \| { topLeft?, topRight?, bottomRight?, bottomLeft? }` | прямые углы | Радиус каждого угла прямоугольников. |
| `color` | `.color(..)` | `keyof T \| (d: T, index: number) => string` | нет | Категория каждого узла, которой `ordinal` `colorScale` задаёт цвет. |
| `colorValue` | `.color_value(..)` | `keyof T \| (d: T, index: number) => number` | нет | Число каждого узла, из которого `sequential` или `diverging` `colorScale` делает его цвет. |
| `leafClass` | `.leaf_class(..)` | `string \| (d: T, index: number) => string` | нет | Классы каждого листа (`fill-*`, `stroke-*`): одинаковые для всех листьев или функция его строки. |
| `leafStyle` | `.leaf_style(..)` | `StyleProp<ViewStyle>` | нет | Стиль каждого листа поверх его классов. |
| `groupClass` | `.group_class(..)` | `string \| (d: T, index: number) => string` | нет | Классы каждой группы — прямоугольника под её дочерними узлами; группы рисуются только при наличии класса или стиля. |
| `groupStyle` | `.group_style(..)` | `StyleProp<ViewStyle>` | нет | Стиль каждой группы поверх её классов. |
| `inactiveOpacity` | `.inactive_opacity(..)` | `number` (от 0 до 1) | нет | Непрозрачность узлов, кроме находящегося под указателем, и их подписей, с затуханием при наведении. |
| `label` | `.label(..)` | `keyof T \| (d: T, index: number) => string` | нет | Имя в каждом листе, в одну строку, обрезается многоточием. |
| `valueLabel` | `.value_label(..)` | `(d: T, value: number, index: number) => string` | нет | Значение в каждом листе под именем; показывается, только если помещается целиком. |
| `labelPlacement` | `.label_placement(..)` | `"start" \| "center"` | `"start"` | Подписи в левом верхнем углу своих листьев или по центру. |
| `labelClass` | `.label_class(..)` | `string` | нет | Классы подписи каждого листа — блока размером с лист: его padding и стили текста; `data-dark:` на тёмных листьях. |
| `labelStyle` | `.label_style(..)` | `StyleProp<TextStyle>` | нет | Стиль подписи каждого листа поверх её классов. |
| `valueLabelClass` | `.value_label_class(..)` | `string` | нет | Классы строки значения поверх классов подписи. |
| `valueLabelStyle` | `.value_label_style(..)` | `StyleProp<TextStyle>` | нет | Стиль строки значения поверх её классов. |
| `groupLabel` | `.group_label(..)` | `keyof T \| (d: T, index: number) => string` | нет | Имя каждой группы в её полосе, обрезается многоточием. |
| `groupLabelClass` | `.group_label_class(..)` | `string` | нет | Классы подписи каждой группы — блока размером с полосу. |
| `groupLabelStyle` | `.group_label_style(..)` | `StyleProp<TextStyle>` | нет | Стиль подписи каждой группы поверх её классов. |
| `stagger` | `.stagger(..)` | `number` (от 0 до 0.95) | `0` | Как узлы верхнего уровня появляются один за другим: доля анимации появления, в течение которой они начинают появляться. |
| `hoverClass` | `.hover_class(..)` | `string` | нет | Классы части `hover` — представления размером с узел под указателем, поверх него (`rounded-xs outline-2 outline-foreground`). |
| `hoverStyle` | `.hover_style(..)` | `StyleProp<ViewStyle>` | нет | Стиль части наведения поверх её классов. |

Прямоугольник заливается цветом `fill` (цвет `colorScale`, а если он не задан — `currentColor`) и обводится `stroke` (если не задано — без обводки); собственные значения переопределяют значения treemap и графика, наследуемые так, как наследует CSS. Раскладка выполняется заново только при изменении строк, параметров или размера графика, но не из-за указателя и не из-за анимации появления; изменение размера графика ничего не перемещает. Повторяющийся `id`, `parent`, не указывающий ни на одну строку, и строка, являющаяся собственным предком, один раз записывают предупреждение в журнал: первая строка сохраняет идентификатор, «сирота» становится узлом верхнего уровня, цикл не рисуется.

## PlotAxis

`PlotAxis` подписывает край графика делениями шкалы, которую несёт этот край: нижний и верхний края — шкалы x, левый и правый — шкалы y, как `axisBottom`, `axisTop`, `axisLeft` и `axisRight` в d3. Его деления берутся из шкалы: круглые значения на `linear` шкале (1, 2 или 5, умноженные на степень десяти, внутри домена), строки — на `point` или `band` шкале. Его подписи — это тексты, которые размещает раскладка графика: приложение задаёт, что они говорят и как выглядят, а раскладка ставит их на место: каждая подпись центрируется на своём делении вдоль края. Вдоль нижнего и верхнего краёв первая и последняя подписи сдвигаются внутрь ровно настолько, насколько нужно, чтобы остаться в пределах концов графика; у левого и правого краёв каждая подпись остаётся на своём делении, так что читается как подпись своей линии сетки, а там, где подпись у конца вышла бы за верхний или нижний край графика, линейные шкалы y графика начинаются на столько же отступя от него (половина высоты подписи для деления на конце), так что каждая подпись остаётся в пределах графика, на своей линии сетки.

```tsx
import { Area, Grid, Line, Plot, PlotAxis } from "nsh/base/plot";

const rows = [
  { day: "Mon", visitors: 1820 },
  { day: "Tue", visitors: 2140 },
  { day: "Wed", visitors: 1960 },
  { day: "Thu", visitors: 2480 },
  { day: "Fri", visitors: 2310 },
];

<Plot data={rows} y="visitors" yScale={{ type: "linear", domain: [0, 3000] }} class="mb-6 ml-10 h-40">
  <Grid y={[0, 1000, 2000, 3000]} class="stroke-border" />
  <Area class="fill-chart-1/20" />
  <Line class="stroke-chart-1 stroke-2" />
  {/* 0, 1,000, 2,000 and 3,000 left of the plot; a day under each row. */}
  <PlotAxis placement="left" ticks={3} labelClass="pr-2 text-xs tabular-nums text-muted-foreground" />
  <PlotAxis
    format={(i) => rows[i].day}
    labelClass="pt-2 text-xs text-muted-foreground"
    tickClass="h-1 w-px bg-border"
    lineClass="h-px bg-border"
  />
</Plot>
```

| Проп | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
| `placement` | `.placement(..)` | `"bottom" \| "top" \| "left" \| "right"` | `"bottom"` | Край, вдоль которого идёт ось: нижний и верхний края несут деления шкалы x, левый и правый — шкалы y. |
| `scale` | `.scale(..)` | `"y" \| "y2"` | `"y"` | Шкала y, которую подписывает левая или правая ось: `yScale` графика или его вторая шкала, `y2Scale` ([Двойные оси](#dual-axes)). |
| `labelPlacement` | `.label_placement(..)` | `"outside" \| "inside"` | `"outside"` | Где находятся подписи: за краем, в пространстве, которое приложение оставляет вокруг графика, где они не пересекаются с метками; или поверх графика вдоль края, так что график сохраняет полный размер (у левого или правого края каждая подпись находится с верхней стороны своей линии сетки). |
| `ticks` | `.ticks(..)` | `number` | `10` на `linear` шкале (каждый шаг выровненных шкал), на остальных — каждая строка | Примерно сколько делений берётся: на `linear` шкале — круглые значения внутри домена, примерно столько; на `point` или `band` шкале — столько строк, равномерно от первой до последней. |
| `tickValues` | `.tick_values(..)` | `number[]` | нет | Значения делений вместо `ticks`: значения шкалы, на `point` или `band` шкале — индексы строк (`[0, 3, 6]` подписывает каждую третью строку). Каждое значение внутри домена получает деление. |
| `format` | `.format(..)` | `(value: number, index: number) => string` | категория полосы на band-шкале категорий, иначе значение с числом десятичных знаков, которое требуется шагу между делениями, с группировкой тысяч | Текст каждого деления: функция приложения от его значения (индекса строки на `point` или `band` шкале) и его индекса среди делений оси. |
| `labelClass`, `labelStyle` | `.label_class(..)`, `.label_style(..)` | `string`, `StyleProp<TextStyle>` | нет | Часть `label`: текст на каждое деление, его шрифт, цвет и padding задаёт приложение; его padding в сторону графика — расстояние от края (`pt-2` под нижней осью). |
| `tickClass`, `tickStyle` | `.tick_class(..)`, `.tick_style(..)` | `string`, `StyleProp<ViewStyle>` | нет | Часть `tick`: представление на каждое деление на краю, центрированное на делении и тянущееся к подписям (`h-1 w-px bg-border` под нижней осью); показывается, как только получает стиль. |
| `lineClass`, `lineStyle` | `.line_class(..)`, `.line_style(..)` | `string`, `StyleProp<ViewStyle>` | нет | Часть `line`: представление вдоль всего края, со стороны подписей (`h-px bg-border` под нижней осью); показывается, как только получает стиль. |

При размещении снаружи подписи находятся в поле графика, как оси d3 — в полях диаграммы: график сохраняет размер, который задаёт ему приложение, а приложение отводит ему поле со стороны оси, глубиной с подписи (`mb-6` для подписей `pt-2 text-xs` под ним), либо используется padding его родителя. Две оси на одном краю подписывают каждая свои значения, в своём стиле: концы диапазона бледные, а середина плотнее.

`format` вызывается один раз на каждое деление при изменении делений или функции, на следующем такте, и никогда — пока график рисуется или следует за указателем: сохраняйте её чистой (то же значение — тот же текст). Без неё деление выглядит так, как его пишет d3: `1,500`, `0.25`, `−2`. В Rust `plot_axis()` создаёт ось, её `format` — `TickFormat::from_fn(|value, index| ..)`. Ось не принимает указатель, а программы чтения с экрана озвучивают график, а не его подписи.
## PlotLabel

`PlotLabel` пишет тексты в точках графика: подпись над пиком, метку у последней точки линии, заметку у конца линии сетки. Каждый из его `texts` задаёт свою точку значениями шкал графика, как это делают метки: индекс строки на шкале `point` или `band` (середина полосы строки), значение на шкале `linear`. Верхний край текста находится в точке, а `align` начинает текст от неё, центрирует относительно неё или заканчивает в ней. Раскладка графика размещает тексты, поэтому они следуют за размером графика; они плавно движутся вместе с доменами шкал (`motion.update`) и появляются с затуханием при появлении графика.

```tsx
import { Area, Line, Plot, PlotLabel } from "nsh/base/plot";

const rows = [
  { day: "Mon", visitors: 1820 },
  { day: "Tue", visitors: 2140 },
  { day: "Wed", visitors: 1960 },
  { day: "Thu", visitors: 2480 },
  { day: "Fri", visitors: 2310 },
];
const [peak, last] = [3, rows.length - 1];

<Plot data={rows} y="visitors" yScale={{ type: "linear", domain: [0, 3000] }} class="mr-12 h-40">
  <Area class="fill-chart-1/20" />
  <Line class="stroke-chart-1 stroke-2" />
  {/* The peak's value over it, and a tag right of the last point, its middle at the point's height. */}
  <PlotLabel
    class="text-xs tabular-nums text-muted-foreground"
    texts={[
      { text: `High ${rows[peak].visitors}`, x: peak, y: rows[peak].visitors, align: "center", class: "-translate-y-full pb-1" },
      { text: `${rows[last].visitors}`, x: last, y: rows[last].visitors, class: "-translate-y-1/2 ml-2 rounded bg-chart-1 px-1.5 text-white" },
    ]}
  />
</Plot>
```

| Проп | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
| `texts` | `.texts(..)` | `PlotText[]` | `[]` | Тексты, которые он пишет, каждый в своей точке. |
| `scale` | `.scale(..)` | `"y" \| "y2"` | `"y"` | Шкала y, значениями которой являются `y` его текстов ([Две оси](#dual-axes)). |

Каждый текст — это `PlotText`:

| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| `text` | `string` | | Что он читает. |
| `x` | `number` | | Где он находится по оси x: значение шкалы x или индекс строки на шкале `point` или `band` (`0.5` — ровно посередине между первыми двумя строками). |
| `y` | `number` | | Где находится его верхний край по оси y: значение шкалы y. |
| `align` | `"left" \| "center" \| "right"` | `"left"` | Как он располагается по x относительно своей точки: начинается в ней, центрируется на ней или заканчивается в ней. |
| `class`, `style` | `string`, `StyleProp<TextStyle>` | нет | Собственные классы и стиль текста поверх того, что он наследует от метки: его блок (фон и отступы тега) и расстояние до точки. |

Внешний вид определяет приложение: `class` метки задаёт всем текстам наследуемые стили текста (шрифт, размер, цвет), а собственные `class` и стиль `style` текста оформляют только его. Текст размещается над своей точкой с помощью `-translate-y-full`, а его отступ — это расстояние до точки (`pb-1`); тег рядом с последней точкой линии находится в поле, которое приложение оставляет с этой стороны (`mr-12`), как и метки оси. В Rust `plot_label()` создаёт метку, а каждый текст — это `PlotText::new(text, x, y)` с `.align(PlotTextAlign::Center)`, `.class(..)` и `.style(..)`. Указатель проходит сквозь тексты к графику, а программы чтения с экрана озвучивают график, чей `accessibilityLabel` сообщает, на что указывают тексты.

<a id="motion"></a>

## Анимация

Анимация задаётся приложением и по умолчанию отсутствует: всё показывается сразу. `motion` принимает тайминги [`nsh`](https://nsh.dev/translations/ru/modules/nsh.md#animation) (`{ duration, easing, delay }` и `{ stiffness, damping, mass }`):

| Ключ | Тип | Что движется |
|---|---|---|
| `appear` | timing | Метки прорисовываются с `appearGeneration`: линии и области — от передней кромки, столбцы вырастают от основания (по очереди с `stagger` столбца); точки вырастают из ничего (по очереди с `stagger` точки); ячейки вырастают из середин (по очереди с `stagger` ячейки); кольцо круговой диаграммы и дуги разворачивается по часовой стрелке от своего начала; радиальная линия вырастает от внутреннего радиуса вместе со своими точками; узлы и ленты диаграммы Санкея — от передней кромки, а их подписи появляются с затуханием. |
| `update` | timing | Метки переходят к новой форме, когда приложение её меняет: точки — к новым значениям и размерам, точка новой строки вырастает, а точка исчезнувшей уменьшается; ячейки — к новым цветам (смешение в OKLab) и полосам, ячейка новой строки вырастает, а ячейка исчезнувшей уменьшается; радиусы круговой диаграммы и дуги, так что отверстие кольцевой диаграммы раскрывается и закрывается, и их углы, так что шкала-индикатор или сектор разворачивается к новому значению (`arcTween` из d3: движутся углы, а не контур; новый сектор раскрывается оттуда, где заканчивается предыдущий; изменение во время движения продолжается оттуда, где нарисовано); точки радиальной линии — к новым значениям, углам и радиусам (новая строка вырастает от внутреннего радиуса), а вместе с ними кольца радиальной сетки; и домен линейной шкалы, будь то `domain` приложения или экстент значений меток по мере поступления строк, плавно переходит от старого к новому, а вместе с ним метки, линии сетки, `PlotAxis` и `PlotLabel`; и узлы, ленты и подписи диаграммы Санкея — от раскладки старых потоков к новой, причём изменение во время движения продолжается оттуда, где они нарисованы. |
| `enter` | timing | Части подсветки плавно появляются, когда указатель заходит на график. |
| `exit` | timing | Они плавно исчезают после того, как он уходит. |
| `pointer` | spring | Они плавно переходят от одной строки к следующей, когда клавиши или программа чтения с экрана перемещают подсветку; сектор круговой диаграммы при этом приподнимается. |

Части подсветки следуют за указателем в том же кадре: пока он движется, перекрестная линия, точки и подсказка сразу оказываются на строке под ним и поэтому отслеживают руку, а их появление и исчезновение (`enter`, `exit`) по-прежнему анимируются.

Анимация выполняется аниматором платформы без JS на каждый кадр и завершается мгновенно для тех, кто просит уменьшить движение. График, который список создаёт заново для вернувшейся строки, прорисовывается снова, если только [`PlotAppearScope`](#plotappearscope) вокруг списка не видел завершения его появления.

Домен движется так, как d3 анимирует домен шкалы: промежуточная шкала размещает каждое значение, поэтому метки сохраняют свою форму, линии сетки и части подсветки движутся вместе с ними, столбцы сохраняют свои углы, а строка, появляющаяся с новым доменом, движется вместе с остальными. Живой график, который время от времени подгоняет шкалу y под свои строки (с запасом, чтобы сетка стояла на месте между подгонками), плавно переходит к каждой новой подгонке с таймингом `update`. Когда размер графика меняется, метки сразу принимают новый размер.

Это собственная анимация графика, тайминги того, что движется в диаграмме: обрезка его меток и части его подсветки. Входы, выходы и привлекающие внимание анимации представления — это пресеты и ключевые кадры [`createMotion`](https://nsh.dev/translations/ru/modules/nsh.md#createmotion), которые приложение по-прежнему может проигрывать на представлении графика или на окружающих его элементах.

## PlotAppearScope

Список заново создаёт график строки каждый раз, когда строка возвращается в область видимости, поэтому спарклайн с появлением прорисовывается снова всякий раз, когда список прокручивается обратно к нему. `PlotAppearScope` вокруг списка запоминает завершившиеся в нём появления: график, созданный там заново, у которого `appearGeneration` завершилось, сразу показывает свои метки целиком. График запоминается по его поколению, поэтому у графика каждой строки оно своё: ключ строки вместе с тем, что должно запускать повтор (`` `${symbol} ${period}` ``).

```tsx
import { List } from "nsh";
import { Line, Plot, PlotAppearScope } from "nsh/base/plot";

<PlotAppearScope id={watchlist().id} class="flex-1">
  <List each={watchlist().rows} keyExtractor={(row) => row.symbol} estimatedItemSize={48} class="flex-1">
    {(row) => (
      <Plot
        data={row().closes}
        y="close"
        interactive={false}
        appearGeneration={`${row().symbol} ${period()}`}
        motion={{ appear: { duration: 400, easing: "easeOut" } }}
        class="h-8 w-24"
      >
        <Line class="stroke-chart-1 stroke-[1.5]" />
      </Plot>
    )}
  </List>
</PlotAppearScope>
```

| Проп | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
| `id` | `.id(..)` | `number \| string` | нет | Для чего область запоминает: когда оно меняется, область забывает, и её графики прорисовываются заново на месте. Называйте его по содержимому, например список наблюдения или беседа; без него область помнит, пока она показана. |
| `children` | `.child(..)` | `JSX.Element` | нет | Что она содержит: список или всё, что заново создаёт её графики по мере их появления и ухода. |

Новое поколение по-прежнему прорисовывает график, как и график, убранный до завершения своего появления, — с самого начала. Как только график завершил новое поколение, его область забывает предыдущее, поэтому возврат к более раннему периоду снова прорисовывает график. Память уходит вместе с областью: область, которая больше не показана, или область с новым `id` прорисовывает свои графики заново, а график запоминает в самой внутренней области вокруг него. Её внешний вид определяет приложение: `class` и `style` раскладывают её, как любое представление (`flex-1` вокруг списка, заполняющего экран). В Rust её создаёт `plot_appear_scope()`.

<a id="keyboard"></a>

## Клавиатура

Интерактивный график — это остановка для Tab (macOS, веб), и пока у него фокус клавиатуры, его клавиши двигают ту же подсветку, что и указатель: перекрестная линия, точки и подсказка плавно движутся на пружине `pointer`, сектор круговой диаграммы приподнимается, `onHoverChange` сообщает о строке, а программы чтения с экрана зачитывают `accessibilityValue`, которое приложение задаёт для неё. Кольцо фокуса определяет приложение: класс `focus-visible:` или `focusStyle` (`focus-visible:outline-2 focus-visible:outline-ring`).

| Клавиши | Что они делают |
|---|---|
| Right, Left | Переходят к строке с соответствующей стороны: к следующей или предыдущей строке, а на обращённой шкале x, где первая строка справа, — наоборот (над круговой диаграммой — по её секторам в порядке строк; над диаграммой Санкея — по её узлам; над древовидной картой — к листу рядом на экране, как и клавиши Up и Down); если ни одна строка не подсвечена, Right начинает со строки на левом краю, а Left — со строки на правом краю (над круговой диаграммой — с первого и последнего сектора). |
| Home, End | Переходят к первой или последней строке. |
| Up, Down | Над ячейками: переходят к ячейке выше или ниже в том же столбце. Над ячейками клавиши следуют сетке на экране (сетка данных WAI-ARIA): Right и Left переходят к следующей ячейке вдоль линии, Home и End — к первой и последней ячейке линии; если ни одна ячейка не подсвечена, первая клавиша начинает с левой верхней ячейки (Left, Up и End — с правой нижней). На краю подсветка остаётся на месте. |
| Escape | Снимает подсветку. |

График реагирует на свои клавиши, пока у него есть фокус, а на Escape — пока подсвечена строка; в остальное время они достаются приложению и окружающим представлениям. Уход фокуса снимает подсветку, созданную клавишами. График не принимает нажатий, поэтому график внутри нажимаемой карточки оставляет карточке её нажатия. Программы чтения с экрана тоже переходят по строкам: смахивание вверх и вниз в VoiceOver, прокрутка вперёд и назад в TalkBack и любая программа чтения с экрана, предлагающая действия `increment` и `decrement` элемента, переходят к следующей и предыдущей строке, на обращённой шкале — тоже в порядке строк.

<a id="building-charts"></a>

## Построение графиков

Диаграмма — это график, его метки, его оси и собственные элементы приложения вокруг них. Задайте шкале y `domain`, а её оси — значения сетки (`tickValues`): тогда каждая подпись окажется у своей линии сетки. Перекрестная линия, точки и подсказка подсветки — это части графика, которые оформляет и заполняет приложение; значения строки находятся там, где их показывает приложение.

Сетки лучше всего читаются как сплошные тонкие линии с более плотной базовой линией, что оставляет пунктирную линию свободной для обозначения опорного значения (цена открытия ниже). Область читается легче как заливка, которая угасает к своей базовой линии, чем как сплошная. Точка подсветки каждой серии берёт цвет своей серии, а цвет поверхности, на которой лежит диаграмма, — как своё кольцо, поэтому она вырезается из своей линии, а подсказка находится в верхней части графика, внутри его краёв на первой и последней строках: `domain` с запасом над наибольшим значением удерживает её вдали от линии.

```tsx
import { signal, Text, View } from "nsh";
import { Area, Grid, Line, Plot, PlotAxis } from "nsh/base/plot";

type Row = { time: string; price: number };

export function PriceChart(props: { rows: Row[]; low: number; high: number }) {
  const hovered = signal<number | null>(null);
  const shown = () => props.rows[hovered() ?? props.rows.length - 1];
  const middle = () => (props.low + props.high) / 2;
  return (
    <View class="gap-3 rounded-xl border border-border bg-card p-4">
      {/* The value of the row under the pointer, or the last one. */}
      <View class="flex-row items-baseline">
        <Text class="flex-1 text-sm text-muted-foreground">{shown().time}</Text>
        <Text class="text-right text-xl font-semibold tabular-nums text-foreground">{shown().price.toFixed(2)}</Text>
      </View>
      <Plot
        data={props.rows}
        y="price"
        yScale={{ type: "linear", domain: [props.low, props.high] }}
        accessibilityLabel={`Price, from ${props.rows[0].price} to ${props.rows[props.rows.length - 1].price}`}
        accessibilityValue={{ text: `${shown().time}, ${shown().price.toFixed(2)}` }}
        appearGeneration={props.rows.length}
        motion={{ appear: { duration: 600, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 300, damping: 30 } }}
        crossLineClass="w-px h-full bg-muted-foreground/50"
        tooltipOffset={4}
        tooltipContent={
          <View class="flex-row items-center gap-1.5 rounded-lg border border-border bg-popover px-2 py-1 shadow-md">
            <View class="h-0.5 w-2.5 rounded-full bg-chart-1" />
            <Text class="text-xs tabular-nums text-popover-foreground">{shown().price.toFixed(2)}</Text>
          </View>
        }
        onHoverChange={({ index }) => hovered.set(index)}
        class="mr-10 mb-6 h-48"
      >
        <Grid y={[middle(), props.high]} class="stroke-border" />
        {/* A wash that fades out toward the baseline. */}
        <Area backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" />
        <Grid y={[props.low]} class="stroke-muted-foreground/40" />
        <Grid y={[props.rows[0].price]} class="stroke-muted-foreground" style={{ strokeDasharray: "3 3" }} />
        <Line class="stroke-chart-1 stroke-2" hoverDotClass="size-2.5 rounded-full border-2 border-card bg-chart-1" />
        {/* The grid's values right of the plot, and the first and last times under it. */}
        <PlotAxis
          placement="right"
          tickValues={[props.low, middle(), props.high]}
          format={(v) => v.toFixed(0)}
          labelClass="pl-2 text-xs tabular-nums text-muted-foreground"
        />
        <PlotAxis
          tickValues={[0, props.rows.length - 1]}
          format={(i) => props.rows[i].time}
          labelClass="pt-2 text-xs text-muted-foreground"
        />
      </Plot>
    </View>
  );
}
```

Столбчатая диаграмма лучше всего читается с закруглёнными вершинами столбцов и прямыми основаниями (`cornerRadii={{ topLeft: 4,
topRight: 4 }}`; in a stack only on its top bar), a hair of room between the bars of a band (`groupPadding`), с приглушёнными столбцами других строк, пока одна подсвечена (`inactiveOpacity`), и с подкрашенной её полосой (`crossLineClass`), а также со значениями мелкими табличными цифрами над столбцами и с итогом над стопкой. Если `paddingOuter` равен половине `paddingInner`, середины полос оказываются через равные шаги, а `PlotAxis` центрирует каждый месяц под его полосой; `maxBandWidth` оставляет несколько столбцов узкими в широкой карточке.

```tsx
import { signal, Text, View } from "nsh";
import { Bar, Grid, Plot, PlotAxis } from "nsh/base/plot";

type Sale = { month: string; online: number; store: number };

export function SalesChart(props: { rows: Sale[]; stacked: boolean }) {
  const hovered = signal<number | null>(null);
  const shown = () => props.rows[hovered() ?? props.rows.length - 1];
  const tip = { topLeft: 4, topRight: 4 };
  return (
    <View class="gap-3 rounded-xl border border-border bg-card p-4">
      <View class="flex-row items-baseline">
        <Text class="flex-1 text-sm text-muted-foreground">{shown().month}</Text>
        <Text class="text-right text-sm tabular-nums text-foreground">{`${shown().online + shown().store}k`}</Text>
      </View>
      <Plot
        data={props.rows}
        xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15, maxBandWidth: 56, groupPadding: 0.1 }}
        yScale={{ type: "linear", domain: [0, 130] }}
        accessibilityLabel="Sales per month, online and in store"
        appearGeneration={props.stacked ? 1 : 0}
        motion={{ appear: { duration: 600, easing: "easeOut" }, enter: { duration: 150 }, exit: { duration: 150 }, pointer: { stiffness: 320, damping: 32 } }}
        crossLineClass="w-full h-full bg-foreground/5"
        onHoverChange={({ index }) => hovered.set(index)}
        class="mb-6 h-44"
      >
        <Grid y={[40, 80, 120]} class="stroke-border" />
        <Bar
          value="online"
          stack={props.stacked ? "sales" : undefined}
          cornerRadii={props.stacked ? 0 : tip}
          stagger={0.4}
          inactiveOpacity={0.45}
          label={props.stacked ? undefined : "online"}
          labelClass="pb-1 text-[11px] tabular-nums text-muted-foreground"
          class="fill-chart-1"
        />
        <Bar
          value="store"
          stack={props.stacked ? "sales" : undefined}
          cornerRadii={tip}
          stagger={0.4}
          inactiveOpacity={0.45}
          label={props.stacked ? (d) => String(d.online + d.store) : "store"}
          labelClass="pb-1 text-[11px] tabular-nums text-muted-foreground"
          class="fill-chart-2"
        />
        {/* A month centered under each band. */}
        <PlotAxis format={(i) => props.rows[i].month} labelClass="pt-2 text-xs text-muted-foreground" />
      </Plot>
    </View>
  );
}
```

Спарклайн в строке списка — это график с `interactive={false}`: он рисует свои метки и не следует за указателем.

<a id="accessibility"></a>

## Доступность

- Для программ чтения с экрана график — один элемент с ролью изображения и `accessibilityLabel` приложения; его метки, подписи его осей, его `PlotLabel` и части подсветки являются его частями и сами ничего не сообщают. Укажите в подписи, что показывает диаграмма и что выделяется (диапазон, наибольшее значение), и дайте `accessibilityValue` слова для строки под указателем, чтобы программа чтения с экрана озвучивала значение по мере его изменения.
- Строка под указателем сообщается через `onHoverChange` на платформах с указателем (настольные системы, веб, iPad с трекпадом). Сенсорные экраны не сообщают о наведении: показывайте нужные пользователю касаний значения в собственных элементах приложения или предлагайте строки в виде текста или списка там, где важны числа.
- Над круговой диаграммой сообщается строка сектора под указателем: дайте `accessibilityValue` его название, значение и долю (`"Team, 340 seats, 34.0%"`), а в подписи укажите итог и наибольшую долю. Над радарной диаграммой это ось, ближайшая к указателю: дайте ей название оси и значение каждой серии на ней.
- Интерактивный график — это остановка фокуса клавиатуры, чьи клавиши со стрелками двигают подсветку ([Клавиатура](#keyboard)), и он предлагает программам чтения с экрана `increment` и `decrement` для перехода по строкам (VoiceOver читает его как регулируемый); график с `interactive={false}` не является ни тем ни другим и в любом случае не принимает нажатий.

Общие сведения о подписях и ролях см. в разделе [Доступность](https://nsh.dev/translations/ru/docs/accessibility.md).
<a id="platform-notes"></a>

## Примечания по платформам

- Каждая платформа рисует метки одинаково: по одному рисунку на метку, записанному в Rust. При изменении строк метки, которые их читают, перерисовываются одним пакетом, а при изменении класса оформления (внешний вид, `dark:`) рисунок отправляется заново без повторной записи.
- Анимация появления обрезает линии и области и наращивает столбцы значениями, которые двигает аниматор платформы. Угасание столбца при удалении от наведённой строки — это прозрачность, которую аниматор вычисляет по положению наведения. Части наведения — это представления, которые аниматор перемещает и плавно скрывает, и он же удерживает подсказку внутри графика, исходя из размера графика и размера подсказки.
- Фон области рисуется в отдельном canvas и обрезается по форме области, с тем же градиентом, который платформа рисует для `bg-linear-*` представления. При изменении его классов (внешний вид, `dark:`) рисунок отправляется заново без повторной записи.
- Круговая диаграмма рисует каждый сектор в отдельном canvas. Сектор под указателем определяется в Rust по углам и радиусам секторов. Его выдвижение — это радиус, а угасание остальных — прозрачность, которые двигает аниматор. Развёртка при появлении так же двигает углы секторов, поэтому ни то ни другое не требует повторной записи рисунка.
- Строка радиальной линии под указателем определяется в Rust по углам её точек. Точка наведения — это представление, которое аниматор помещает на строку под указателем (она скользит по пружине `pointer` при смене ключей). Пока линия появляется или переходит к новым значениям, её точки — это числа, которые аниматор двигает от прежних положений, поэтому линия записывается один раз на изменение.
- Метка-точка рисуется в одном canvas. Точка под указателем определяется в Rust по центрам и радиусам точек, а точка наведения — это представление, которое аниматор помещает на неё, того же размера. Рост при появлении, перемещения при изменении и угасание наведения — это значения, которые двигает аниматор, поэтому точки записываются один раз на изменение.
- Метка-ячейка рисуется в одном canvas. Ячейка под указателем определяется в Rust по прямоугольникам ячеек, а ячейка наведения — это представление, которое аниматор помещает на неё. Рост, смена цветов и перемещения при изменении, а также угасание наведения — это значения, которые двигает аниматор, поэтому ячейки записываются один раз на изменение. Каждая подпись — это текст платформы, который показывается там, где его вычисленный размер помещается внутри ячейки.
- Подписи, засечки и линия оси — это собственные тексты и представления платформы, которые размещает раскладка в Rust по размеру графика и шкалам. При изменении размера графика они двигаются вместе с метками, а новая область определения записывает подписи заново.
