Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

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-*) внутри своей формы, а части наведения (в том числе точка каждой метки) и подписи, засечки и линии осей стилизуются и заполняются приложением.

el-plot.tsx
el-plot.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Area, Bar, Grid, Line, Plot, PlotAxis } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// A price chart built from the plot's parts: the rows are the app's, the
// look is the palette's (paint as styles: `stroke`, `fill`,
// `strokeDasharray`). The y domain is the app's, and its axis labels the
// grid's values; the axes lay their labels along the plot's edges. The row
// under the pointer drives the value in the header, and the plot's cross
// line, dot and tooltip follow it natively. Grid lines
// are solid hairlines and the bottom one a firmer baseline; the one dashed
// line is a reference, the opening price the change is measured from. The
// area is a wash of the line's color that fades out toward the baseline
// (its background, a gradient inside its shape), and the line has the
// hover's dot, in its color. The tooltip is the time of the row, a tag
// over the x axis' labels, so it never covers the line, and it stays
// within the plot's width at the first and last rows. Numbers are tabular
// and at the trailing edge.
//
// The other chart is sales per month as bars in the bands of a band scale,
// side by side or stacked: tips rounded and bases square, a hair between
// the bars of a month, a value over each (the total over a stack), the
// month under the pointer a faint band while the other months' bars fade,
// and the bars growing in one month after another.
type Row = { minute: number; price: number };
type Range = "1D" | "1W" | "1M";
type Curve = "natural" | "linear" | "stepAfter";

// A repeatable walk, so every visit shows the same chart.
const walk = (count: number, seed: number, start: number, step: number): Row[] => {
  let price = start;
  let s = seed;
  return Array.from({ length: count }, (_, minute) => {
    s = (s * 16807) % 2147483647;
    price += ((s % 1000) / 1000 - 0.47) * step;
    return { minute, price: Math.round(price * 100) / 100 };
  });
};
// The time of each row: every five minutes of a session, seven an hour
// apart each day of a week, and the trading days of October 2026.
const clock = (minutes: number) => `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`;
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"];
const OCTOBER = Array.from({ length: 31 }, (_, d) => d + 1).filter((d) => (d + 3) % 7 !== 0 && (d + 3) % 7 !== 6);
const RANGES: Record<Range, { rows: Row[]; ticks: string[]; period: string; time: (row: number) => string }> = {
  "1D": { rows: walk(78, 7, 188, 1.2), ticks: ["9:30", "12:45", "16:00"], period: "Today", time: (i) => clock(570 + i * 5) },
  "1W": { rows: walk(35, 11, 181, 2.4), ticks: ["Mon", "Wed", "Fri"], period: "Past week", time: (i) => `${DAYS[Math.floor(i / 7)]} ${clock(600 + (i % 7) * 60)}` },
  "1M": { rows: walk(22, 5, 172, 3.6), ticks: ["Oct 1", "Oct 15", "Oct 30"], period: "Past month", time: (i) => `Oct ${OCTOBER[i]}` },
};

type Sale = { month: string; online: number; store: number };
type Mode = "grouped" | "stacked";
/** Sales per month, in thousands. */
const SALES: Sale[] = [
  [42, 31],
  [51, 28],
  [48, 36],
  [63, 33],
  [58, 40],
  [71, 38],
  [66, 44],
].map(([online, store], i) => ({ month: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"][i], online, store }));
const total = (d: Sale) => d.online + d.store;
/** The top of the sales' y scale: room above the highest stack for its
 * label. */
const SALES_TOP = 130;

// The y domain: the rows' extent, rounded out to whole steps of 5.
const domain = (rows: Row[]): [number, number] => {
  const prices = rows.map((r) => r.price);
  return [Math.floor(Math.min(...prices) / 5) * 5, Math.ceil(Math.max(...prices) / 5) * 5];
};

const num = { fontVariant: ["tabular-nums" as const], textAlign: "right" as const };
/** The room under a plot for its x axis' labels: their 12 above a line of 14. */
const AXIS_BELOW = 12 + 14;

/** A series' key: a swatch in its color, its name and the shown month's value. */
function Key(props: { color: string; label: string; value: string }) {
  return (
    <View class="flex-row items-center gap-1.5">
      <View style={{ width: 8, height: 8, borderRadius: 2, backgroundColor: props.color }} />
      <Text style={{ fontSize: size.detail, color: ui().muted }}>{props.label}</Text>
      <Text style={{ fontSize: size.detail, color: ui().text, ...num }}>{props.value}</Text>
    </View>
  );
}

/** Sales per month as bars, side by side or stacked. */
function SalesCard(props: { mode: Mode; onHover: (index: number | null) => void }) {
  const hovered = signal<number | null>(null);
  const shown = () => SALES[hovered() ?? SALES.length - 1];
  const stacked = () => props.mode === "stacked";
  const height = desktop ? 200 : 180;
  // Tips rounded, bases square; a stack only at its top.
  const tip = { topLeft: 3, topRight: 3 };
  const labelStyle = () => ({ fontSize: 11, paddingBottom: 3, color: ui().muted, fontVariant: ["tabular-nums" as const] });
  return (
    <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
      <View class="flex-row items-baseline gap-3">
        <View style={{ flex: 1, gap: 2 }}>
          <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>Sales</Text>
          <Text style={{ fontSize: size.detail, color: ui().muted }}>{`${shown().month} · thousands`}</Text>
        </View>
        <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{`${total(shown())}k`}</Text>
      </View>
      <View class="flex-row gap-4">
        <Key color={ui().info} label="Online" value={`${shown().online}k`} />
        <Key color={ui().violet} label="In store" value={`${shown().store}k`} />
      </View>
      <View>
        <Plot
          data={SALES}
          // Half the inner gap at the ends: the bands' middles divide the
          // width evenly.
          xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15, maxBandWidth: 56, groupPadding: 0.12 }}
          yScale={{ type: "linear", domain: [0, SALES_TOP] }}
          accessibilityLabel={`Sales per month, online and in store, highest in Jul at ${total(SALES[6])}k`}
          accessibilityValue={{ text: hovered() == null ? undefined : `${shown().month}: ${shown().online}k online, ${shown().store}k in store` }}
          appearGeneration={stacked() ? 1 : 0}
          motion={{ appear: { duration: 600, easing: "easeOut" }, enter: { duration: 150 }, exit: { duration: 180 }, pointer: { stiffness: 320, damping: 32 } }}
          // The month under the pointer: a faint band over its bars.
          crossLineClass="w-full h-full"
          crossLineStyle={{ backgroundColor: tint(ui().text, 0.05) }}
          onHoverChange={({ index }) => {
            hovered.set(index);
            props.onHover(index);
          }}
          style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
        >
          <Grid y={[40, 80, 120]} style={{ stroke: tint(ui().text, 0.08) }} />
          <Grid y={[0]} style={{ stroke: tint(ui().text, 0.2) }} />
          <Bar
            value="online"
            stack={stacked() ? "sales" : undefined}
            cornerRadii={stacked() ? 0 : tip}
            stagger={0.4}
            inactiveOpacity={0.45}
            label={stacked() ? undefined : "online"}
            labelStyle={labelStyle()}
            style={{ fill: ui().info }}
          />
          <Bar
            value="store"
            stack={stacked() ? "sales" : undefined}
            cornerRadii={tip}
            stagger={0.4}
            inactiveOpacity={0.45}
            // Stacked, the top bar's label is the month's total.
            label={stacked() ? (d: Sale) => String(total(d)) : "store"}
            labelStyle={labelStyle()}
            style={{ fill: ui().violet }}
          />
          {/* The grid's values right of the plot, a month centered under each band. */}
          <PlotAxis
            placement="right"
            tickValues={[0, 40, 80, 120]}
            labelStyle={{ width: 36, paddingLeft: 8, textAlign: "right", fontSize: 11, color: ui().muted, ...num }}
          />
          <PlotAxis format={(i) => SALES[i].month} labelStyle={{ fontSize: 11, lineHeight: 14, paddingTop: 12, color: ui().muted }} />
        </Plot>
      </View>
    </View>
  );
}

function App() {
  const chart = signal<"price" | "sales">("price");
  const mode = signal<Mode>("grouped");
  const range = signal<Range>("1D");
  const curve = signal<Curve>("natural");
  const hovered = signal<number | null>(null);
  // The last row hovered: the tooltip keeps it while it fades out.
  const tipRow = signal(0);
  const events = signal<string[]>([]);
  const rows = () => RANGES[range()].rows;
  const [lo, hi] = [() => domain(rows())[0], () => domain(rows())[1]];
  // The hovered row, or the last one; a row of the range before a switch
  // reads as the last one of the new range.
  const shown = () => {
    const all = rows();
    const at = hovered();
    return all[at != null && at < all.length ? at : all.length - 1];
  };
  const open = () => rows()[0].price;
  const change = () => shown().price - open();
  const color = () => (change() >= 0 ? ui().up : ui().down);
  // Signed figures: an explicit plus and a true minus.
  const signed = (v: number, digits: number) => `${v >= 0 ? "+" : "−"}${Math.abs(v).toFixed(digits)}`;
  const label = (v: number) => v.toFixed(0);
  const height = desktop ? 200 : 180;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot</Text>
      <Text style={{ color: ui().muted }}>
        {desktop
          ? "Move the pointer over the chart to read a row"
          : chart() === "price"
            ? "Pick a range to draw other rows"
            : "Stack the bars to read each month's total"}
      </Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="chart">
              <Segmented options={["price", "sales"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
            </Panel>
            <If
              when={chart() === "price"}
              else={
                <Panel title="bars">
                  <Segmented options={["grouped", "stacked"] as const} value={mode()} onChange={(m) => mode.set(m)} />
                  <Text style={{ fontSize: 13, color: ui().text, ...num, textAlign: "left" }}>
                    {`${SALES.length} bands · ${mode() === "stacked" ? "one stack" : "two bars"} each`}
                  </Text>
                </Panel>
              }
            >
              <Panel title="data · curve">
                <Segmented options={["1D", "1W", "1M"] as const} value={range()} onChange={(r) => (range.set(r), hovered.set(null))} />
                <Segmented options={["natural", "linear", "stepAfter"] as const} value={curve()} onChange={(c) => curve.set(c)} />
                <Text style={{ fontSize: 13, color: ui().text, ...num, textAlign: "left" }}>
                  {`${rows().length} rows · domain ${lo()} to ${hi()}`}
                </Text>
              </Panel>
            </If>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <If
          when={chart() === "price"}
          else={
            <SalesCard
              mode={mode()}
              onHover={(index) => {
                const line = index == null ? "null: left the chart" : `${index}: ${SALES[index].month}, ${total(SALES[index])}k`;
                events.set([line, ...events()].slice(0, 5));
              }}
            />
          }
        >
          <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
            <View class="flex-row items-baseline gap-3">
              <View style={{ flex: 1, gap: 2 }}>
                <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>NSH Labs</Text>
                <Text style={{ fontSize: size.detail, color: ui().muted }}>
                  {`${RANGES[range()].period} · open ${open().toFixed(2)}`}
                </Text>
              </View>
              <View style={{ alignItems: "flex-end", gap: 2 }}>
                <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{shown().price.toFixed(2)}</Text>
                <Text style={{ fontSize: size.detail, color: color(), ...num }}>
                  {`${signed(change(), 2)} (${signed((change() / open()) * 100, 2)}%)`}
                </Text>
              </View>
            </View>

            <View>
              <Plot
                data={rows()}
                y="price"
                yScale={{ type: "linear", domain: [lo(), hi()] }}
                accessibilityLabel={`NSH Labs, ${range()}: from ${rows()[0].price.toFixed(2)} to ${rows()[rows().length - 1].price.toFixed(2)}`}
                accessibilityValue={{ text: hovered() == null ? undefined : `${RANGES[range()].time(hovered()!)}: ${shown().price.toFixed(2)}` }}
                // Motion of the app's: the marks draw in for each range, the
                // hover fades and glides; reduced motion shows it all at once.
                appearGeneration={["1D", "1W", "1M"].indexOf(range())}
                motion={{ appear: { duration: 700, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 300, damping: 30 } }}
                crossLineClass="w-px h-full"
                crossLineStyle={{ backgroundColor: ui().muted }}
                dotClass="size-2.5 rounded-full border-2"
                // The time of the row, at the foot of the cross line, kept
                // within the plot's width.
                tooltipOffset={height + 9}
                tooltipContent={
                  <View style={{ ...ui().raised, height: 20, paddingHorizontal: 6, borderRadius: 6, justifyContent: "center" }}>
                    <Text style={{ fontSize: 11, color: ui().text, ...num }}>{RANGES[range()].time(Math.min(tipRow(), rows().length - 1))}</Text>
                  </View>
                }
                onHoverChange={({ index }) => {
                  hovered.set(index);
                  if (index != null) tipRow.set(index);
                  const line = index == null ? "null: left the chart" : `${index}: ${rows()[index].price.toFixed(2)}`;
                  events.set([line, ...events()].slice(0, 5));
                }}
                style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
              >
                <Grid y={[(lo() + hi()) / 2, hi()]} style={{ stroke: tint(ui().text, 0.08) }} />
                {/* A wash that fades out toward the baseline; the line has the dot. */}
                <Area
                  curve={curve()}
                  backgroundStyle={{ backgroundImage: `linear-gradient(to bottom, ${tint(color(), 0.16)}, ${tint(color(), 0)})` }}
                  hoverDotClass="hidden"
                />
                <Grid y={[lo()]} style={{ stroke: tint(ui().text, 0.2) }} />
                <Grid y={[open()]} style={{ stroke: ui().faint, strokeDasharray: "3 3" }} />
                {/* Its dot in its color, ringed in the card's: it cuts out of the line. */}
                <Line
                  curve={curve()}
                  style={{ stroke: color(), strokeWidth: 2 }}
                  hoverDotStyle={{ backgroundColor: color(), borderColor: ui().card.backgroundColor }}
                />
                {/* The grid's values right of the plot, the range's times under it. */}
                <PlotAxis
                  placement="right"
                  tickValues={[lo(), (lo() + hi()) / 2, hi()]}
                  format={label}
                  labelStyle={{ fontSize: 11, paddingLeft: 8, color: ui().muted, ...num }}
                />
                <PlotAxis
                  ticks={RANGES[range()].ticks.length}
                  format={(_, i) => RANGES[range()].ticks[i]}
                  labelStyle={{ fontSize: 11, lineHeight: 14, paddingTop: 12, color: ui().muted }}
                />
              </Plot>
            </View>
          </View>
        </If>
      </Split>
    </View>
  );
}

mount(App);
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)))
}

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

#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; или полоса на категорию — для ячеек, Cell). При шкале y band по умолчанию используется linear, для горизонтальных столбцов. reverse: true направляет её от правого края (Обратные шкалы).
yScale.y_scale(..)Scale{ type: "linear" }Как ось y размещает значения: наименьшее значение домена — у нижнего края, наибольшее — у верхнего. Без domain — протяжённость того, что рисуют метки. band укладывает полосу на строку от верхнего края вниз, для горизонтальных столбцов. reverse: true направляет её наоборот: наименьшее значение у верхнего края, строки — от нижнего края вверх (Обратные шкалы).
y2Scale.y2_scale(..)LinearScaleнетВторая шкала y, { type: "linear", domain?, reverse? }, для меток, которые на неё ссылаются (scale="y2"): правая шкала диаграммы с двумя осями (Две оси). Без domain — протяжённость значений на ней.
alignTicks.align_ticks(..)booleantrueПока ни одна из шкал y не задаёт домен и метки есть на обеих, обе расширяются до одинаковых круглых шагов, чтобы их деления попадали на одни и те же линии (Две оси). false оставляет каждой протяжённость её значений.
interactive.interactive(..)booleantruePlot следует за указателем и клавиатурой: сообщает строку под ним и показывает части наведения, а также является остановкой Tab, стрелки которой двигают наведение (Клавиатура). false рисует только метки (спарклайн в списке), а нажатия на него уходят окружающим view. Предлагает ли plot клавиатуру вообще, задаётся при его создании.
motion.motion(..)PlotMotionнетТайминг появления, плавного появления и исчезновения наведения и пружина, на которой наведение скользит, когда его двигают клавиши (ниже).
appearGeneration.appear_generation(..)number | stringнетВключает появление: метки отрисовываются при первом показе plot и снова при каждом изменении этого значения (другой символ, другой период). Внутри 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(..)number0Расстояние между подсказкой и тем, что её размещает: верхний край для "top", перекрестная линия и указатель для "pointer".
colorScale.color_scale(..)ColorScaleнетЦвета меток: по категориям, которые читает их color, как scaleOrdinal в d3 ({ type: "ordinal", domain?, range, unknown? }, Цвета по категориям), или по числам, которые value у Cell читает вдоль градиента, как scaleSequential и scaleDiverging в d3 ({ type: "sequential" | "diverging", … }, Цвета по градиенту).
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 под ним, dot, центр которого ближе всего к нему, cell под ним (в промежутке между двумя ячейками — ближайшей) или строки radial line, ближайшей к нему по углу; 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); остальные её опции описаны в Bar.

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

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

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

<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, — от правого, если она обращена.

#Две оси

el-plot-dual.tsx
el-plot-dual.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Bar, Grid, Line, Plot, PlotAxis } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// Two y scales in one plot: the volume bars on the plot's `yScale`, the
// close on its second one (`y2Scale`), which the line names with
// `scale="y2"` and the right-hand `PlotAxis` labels. Neither scale names a
// domain, so the plot widens both to the same round steps (`alignTicks`,
// on by default) and the one `<Grid />`, which follows the ticks, serves
// both axes. Switching the symbol moves both scales to their new domains;
// turning alignment off shows each scale on its own extent.
type Day = { day: number; volume: number; close: number; dir: "up" | "down" };

/** A repeatable walk of a stock's days: its close and its volume in
 * millions of shares, heavier on the days that move more. */
function walk(seed: number, start: number, volume: number): Day[] {
  let s = seed;
  const next = () => {
    s = (s * 16807) % 2147483647;
    return (s % 1000) / 1000;
  };
  let close = start;
  return Array.from({ length: 20 }, (_, day) => {
    const before = close;
    close = Math.round(close * (1 + (next() - 0.47) * 0.04) * 100) / 100;
    const move = Math.abs(close / before - 1);
    return { day, close, volume: Math.round(volume * (0.7 + next() * 0.5) * (1 + move * 18) * 10) / 10, dir: close >= before ? "up" : "down" };
  });
}

const SYMBOLS = {
  ORBX: walk(23, 184, 42),
  QBIT: walk(71, 36.5, 160),
} as const;
type Symbol = keyof typeof SYMBOLS;

const num = { fontVariant: ["tabular-nums" as const] };
/** The room under a plot for its x axis' labels: their 12 above a line of 14. */
const AXIS_BELOW = 12 + 14;
const SIDE = 40;
const shares = (m: number) => (m === 0 ? "0" : m >= 1000 ? `${(m / 1000).toFixed(1)}B` : `${m.toFixed(0)}M`);

function App() {
  const symbol = signal<Symbol>("ORBX");
  const aligned = signal(true);
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const height = desktop ? 200 : 180;
  const rows = () => SYMBOLS[symbol()];
  const day = () => rows()[hovered() ?? rows().length - 1];
  const tone = (dir: string) => (dir === "down" ? ui().down : ui().up);
  const axisLabel = () => ({ fontSize: 11, color: ui().muted, ...num });
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot dual axes</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Move the pointer over the chart to read both values" : "Volume on the left scale, the close on the right"}</Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="symbol">
              <Segmented options={["ORBX", "QBIT"] as const} value={symbol()} onChange={(s) => symbol.set(s)} />
            </Panel>
            <Panel title="alignTicks">
              <Segmented options={["on", "off"] as const} value={aligned() ? "on" : "off"} onChange={(v) => aligned.set(v === "on")} />
              <Text style={{ fontSize: 13, color: ui().text, ...num }}>{aligned() ? "Both scales in the same steps" : "Each scale its own extent"}</Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <View class="flex-row items-baseline gap-3">
            <View style={{ flex: 1, gap: 2 }}>
              <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{symbol()}</Text>
              <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>{`Day ${day().day + 1} · ${shares(day().volume)} shares`}</Text>
            </View>
            <Text style={{ fontSize: 20, fontWeight: "600", color: tone(day().dir), ...num }}>{`$${day().close.toFixed(2)}`}</Text>
          </View>
          <View class="flex-row justify-between">
            <Text style={{ fontSize: 10, letterSpacing: 0.8, color: ui().muted }}>SHARES</Text>
            <Text style={{ fontSize: 10, letterSpacing: 0.8, color: ui().muted }}>USD</Text>
          </View>
          <Plot
            data={rows()}
            xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15 }}
            alignTicks={aligned()}
            colorScale={{ type: "ordinal", domain: ["up", "down"], range: [tint(ui().up, 0.45), tint(ui().down, 0.45)] }}
            accessibilityLabel={`${symbol()}, ${rows().length} days: volume and close`}
            accessibilityValue={{ text: hovered() == null ? undefined : `Day ${day().day + 1}: close ${day().close}, ${shares(day().volume)} shares` }}
            appearGeneration="dual"
            motion={{ appear: { duration: 800, easing: "easeOut" }, update: { duration: 600, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 180 } }}
            crossLineClass="w-full h-full"
            crossLineStyle={{ backgroundColor: tint(ui().text, 0.05) }}
            dotStyle={{ width: 9, height: 9, borderRadius: 4.5, borderWidth: 2, borderColor: ui().info, backgroundColor: ui().page }}
            onHoverChange={({ index }) => {
              hovered.set(index);
              const d = index == null ? undefined : rows()[index];
              log(d == null ? "null: left the chart" : `${index}: $${d.close} · ${shares(d.volume)}`);
            }}
            style={{ height, marginLeft: SIDE, marginRight: SIDE, marginBottom: AXIS_BELOW }}
          >
            {/* No values: lines at the ticks of the y scale, which the right axis' share. */}
            <Grid style={{ stroke: tint(ui().text, 0.08) }} />
            <Bar value="volume" color="dir" cornerRadii={{ topLeft: 1.5, topRight: 1.5 }} stagger={0.5} inactiveOpacity={0.4} />
            <Line y="close" scale="y2" curve="linear" style={{ stroke: ui().info, strokeWidth: 2 }} />
            <PlotAxis placement="left" format={shares} labelStyle={{ ...axisLabel(), width: SIDE, paddingRight: 8, textAlign: "right" }} />
            <PlotAxis placement="right" scale="y2" format={(v) => v.toFixed(0)} labelStyle={{ ...axisLabel(), width: SIDE, paddingLeft: 8 }} />
            <PlotAxis tickValues={[0, 9, 19]} format={(i) => `Day ${i + 1}`} labelStyle={{ fontSize: 11, lineHeight: 14, paddingTop: 12, color: ui().muted }} />
          </Plot>
        </View>
      </Split>
    </View>
  );
}

mount(App);

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

<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 нет.

#Line, Area и Grid

МаркаСвойствоТипПо умолчаниюОписание
Lineystring | (d, index) => numbery графикаЧто она рисует: по одной точке на строку со значением; строки без значения пропускаются.
Line, Areadata, xT[], string | (d, index) => numberстроки графикаСтроки собственной серии (другие значения x), размещаемые на шкалах графика по собственному x (без него — по индексу строки).
Line, Areacurve"natural" | "linear" | "stepAfter""natural"Как она соединяет свои точки: плавной кривой, прямыми отрезками или ступенями, которые держат каждое значение до следующей строки. Нижняя граница области идёт по той же кривой.
Linedot, dotClass, dotStyleboolean, string, StyleProp<ViewStyle>falseМаркер на каждой точке: view с центром в ней; его размер, фон и рамка задаются приложением.
Line, AreahoverDotClass, hoverDotStylestring, StyleProp<ViewStyle>dotClass, dotStyle графикаЕё маркер наведения на её точке в строке под указателем: маркер каждой серии — своего цвета. Каждый из них заменяет соответствующий маркер графика для этой марки (цвет марки поверх размера и кольца графика); hidden не показывает ни одного.
AreabackgroundClass, backgroundStylestring, StyleProp<ViewStyle>нетЕё фон: блок вокруг её фигуры (от первой точки до последней, от самой высокой точки до базовой линии или, у полосы, до самой низкой точки нижней границы), чей фон (bg-*, bg-linear-to-b from-… to-…, backgroundImage) виден только внутри фигуры, под её fill. Если он задан, fill по умолчанию равен none.
Areay1string | (d, index) => numbery графикаЧто образует её верхнюю границу.
Areay0number | string | (d, index) => numberнижний край шкалы yЕё нижняя граница: число задаёт базовую линию на шкале y; поле или функция дают каждой строке своё нижнее значение, и тогда область становится полосой (от минимума дня до его максимума, диапазон прогноза). Без неё область замыкается на край, где лежат наименьшие значения шкалы y: нижний край, а на обращённой шкале — верхний (область, свисающая сверху). Строка без значения на любом из краёв пропускается.
Line, Area, Gridscale"y" | "y2""y"Шкала y, вдоль которой идут её значения: yScale графика или вторая, y2Scale (Двойные оси).
Gridxnumber[]нетГде проходят её вертикальные линии, в значениях шкалы x (индексы строк на шкале point).
Gridynumber[]нетГде проходят её горизонтальные линии, в значениях шкалы y. Если не заданы ни x, ни y, линии идут по делениям её шкалы — там, где их ставит PlotAxis без ticks (по x для горизонтальных столбцов).
Gridticksnumberзначение оси по умолчаниюПримерно сколько линий она рисует, если не заданы ни x, ни y, — как ticks у PlotAxis.
Line, Areacolorstring | (d, index) => stringнетКатегория её строк, чей цвет берётся из colorScale графика: категория первой строки задаёт stroke линии (fill области), если ничто другое его не задаёт (Цвета по категориям).

Каждая марка заполняет график и рисует своими свойствами заливки SVG; собственные свойства марки приоритетнее свойств графика, как при наследовании в CSS:

МаркаРисует с помощьюЕсли не задано
Linestroke, strokeWidth, strokeDasharraycurrentColor, шириной 1, сплошная
Areafill (поверх её части background); её верхняя граница — stroke, strokeWidth, strokeDasharrayfill currentColor (none рядом с фоном); без обводки
Gridstroke, strokeWidth, strokeDasharraycurrentColor, шириной 1, сплошная; каждая линия ложится на целые пиксели устройства, поэтому тонкая остаётся чёткой

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

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

<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 ставит полосу первой строки справа или внизу (Обращённые шкалы).

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 складываются, и столбец каждой строки начинается там, где заканчивается предыдущий в стеке:

<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}`} />
СвойствоТипПо умолчаниюОписание
valuestring | (d, index) => numbery графикаЧто показывает каждый столбец.
basenumber | string | (d, index) => number0Значение, от которого растут её столбцы, на шкале значений: одно для всех строк либо поле или функция, дающие каждой строке своё, так что каждый столбец тянется от базы строки до её значения (у строки без него столбца нет). Оно учитывается в домене. Стек растёт от базы своего первого столбца, строка за строкой.
stackstringнетСтек, к которому она присоединяется: столбцы с одинаковым stack складываются по порядку (строка без значения ничего не добавляет).
scale"y" | "y2""y"Шкала y, вдоль которой идут значения её вертикальных столбцов (Двойные оси); столбцы на обеих шкалах стоят рядом, а стек складывает только столбцы своей шкалы.
cornerRadiinumber | { topLeft?, topRight?, bottomRight?, bottomLeft? }0Радиус каждого угла её столбцов; { topLeft: 4, topRight: 4 } скругляет концы вертикальных столбцов. Радиус больше половины столбца подгоняется под него.
staggernumber0При appear графика столбцы стартуют один за другим, строка за строкой, в пределах этой доли его длительности (от 0 до 0.95), и каждый растёт оставшееся время; 0 растит их одновременно.
inactiveOpacitynumberнетНепрозрачность её столбцов, кроме строки под указателем, пока указатель находится над графиком, чтобы эта строка выделялась: она плавно появляется и исчезает вместе с наведением и переходит от столбца к столбцу вместе с ним.
labelstring | (d, index) => stringнетТекст на конце каждого столбца по значению: поле (его текст или число) либо функция, возвращающая текст, который форматирует приложение.
labelClass, labelStylestring, StyleProp<TextStyle>нетЧасть label: текст, центрированный по столбцу, над концом вертикального столбца (под столбцом, растущим вниз), после горизонтального столбца (перед растущим назад). Её отступ — расстояние до столбца.
backgroundClass, backgroundStylestring, StyleProp<ViewStyle>нетЕё фон: блок каждого столбца, чей фон (bg-linear-to-t from-… to-…, от низа столбца до его верха) виден только внутри столбца, под её fill. Если он задан, fill по умолчанию равен none.
colorstring | (d, index) => stringнетКатегория каждого столбца, чей цвет берётся из colorScale графика: fill столбца, если ничто другое его не задаёт (Цвета по категориям).

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

ОпцияПо умолчаниюОписание
domainнетКатегории её полос по порядку (scaleBand().domain() в d3): Cell попадает в полосу категории своей строки, а PlotAxis выводит категорию каждой полосы; категория, записанная дважды, сохраняет первое место. Без неё полосы — это строки, а у ячеек — категории, которые ячейки называют вдоль оси, в порядке, в котором их приносят строки.
paddingInner0Промежуток между соседними полосами, доля шага каждой полосы (от 0 до 1).
paddingOuter0Промежуток перед первой полосой и после последней, доля шага. Половина paddingInner ставит середины полос на равные шаги поперёк графика.
maxBandWidthнетМаксимальная ширина полосы в пунктах: несколько полос на широком графике остаются узкими, каждая посередине своего места.
bandCountстрокиЧисло полос, на которое рассчитана ось, если их больше, чем строк: строки занимают первые и сохраняют свою ширину и место по мере поступления строк.
groupPadding0Промежуток между столбцами, стоящими рядом в полосе, доля места каждого столбца.
reversefalseПолосы идут от противоположного края: первой строки — справа на оси x, внизу на оси y; столбцы, стоящие рядом в полосе, сохраняют свой порядок.

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

#Диапазоны

el-plot-ranges.tsx
el-plot-ranges.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Bar, Grid, Plot, PlotAxis, Rule } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// Bars that span a range of their own row: `Bar`'s `base` takes a field
// name or a function of the row as well as a number, so each bar runs
// from its row's base to its value. `Rule` draws a straight line per row
// the same way, along the value axis.
//
// The candlestick is two marks in the same bands: a `Rule` from each day's
// low to its high (the wick) under a `Bar` from its open to its close (the
// body), both colored by the day's direction through the plot's ordinal
// `colorScale`. The bridge is one `Bar` whose base is where the running
// total stood before each step: totals stand on zero, costs hang down from
// the level before them, each labeled at its end with its signed change
// (above a rise, below a fall). The marks grow from their bases one after
// another, and the row under the pointer stays lit while the others fade.
type Candle = { day: number; open: number; high: number; low: number; close: number; dir: "up" | "down" };
type Step = { name: string; kind: "total" | "up" | "down"; start: number; end: number };

/** A repeatable walk of daily candles, so every visit shows the same chart. */
const CANDLES: Candle[] = (() => {
  let s = 17;
  const next = () => {
    s = (s * 16807) % 2147483647;
    return (s % 1000) / 1000;
  };
  let close = 92;
  return Array.from({ length: 24 }, (_, day) => {
    const open = close + (next() - 0.5) * 0.8;
    close = open + (next() - 0.46) * 3.2;
    const high = Math.max(open, close) + next() * 1.2;
    const low = Math.min(open, close) - next() * 1.2;
    const r = (v: number) => Math.round(v * 100) / 100;
    return { day, open: r(open), high: r(high), low: r(low), close: r(close), dir: close >= open ? "up" : "down" };
  });
})();
const LOW = Math.floor(Math.min(...CANDLES.map((c) => c.low)) - 1);
const HIGH = Math.ceil(Math.max(...CANDLES.map((c) => c.high)) + 1);

/** A quarter's revenue down to net profit, in millions: each step from
 * where the total stood to where it leaves it. */
const STEPS: Step[] = (() => {
  const lines: [string, number][] = [
    ["Cost", -54.1],
    ["R&D", -22.8],
    ["SG&A", -27.4],
    ["Other", 3.9],
    ["Tax", -9.2],
  ];
  let level = 142.7;
  const steps: Step[] = [{ name: "Revenue", kind: "total", start: 0, end: level }];
  for (const [name, v] of lines) {
    const end = Math.round((level + v) * 10) / 10;
    steps.push({ name, kind: v >= 0 ? "up" : "down", start: level, end });
    level = end;
  }
  steps.push({ name: "Net", kind: "total", start: 0, end: level });
  return steps;
})();

const num = { fontVariant: ["tabular-nums" as const] };
/** The room under a plot for its x axis' labels: their 12 above a line of 14. */
const AXIS_BELOW = 12 + 14;
const signed = (v: number, digits = 1) => `${v >= 0 ? "+" : "−"}${Math.abs(v).toFixed(digits)}`;

function App() {
  const chart = signal<"candles" | "bridge">("candles");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const height = desktop ? 200 : 180;
  const candle = () => CANDLES[hovered() != null && chart() === "candles" ? Math.min(hovered()!, CANDLES.length - 1) : CANDLES.length - 1];
  const step = () => STEPS[hovered() != null && chart() === "bridge" ? Math.min(hovered()!, STEPS.length - 1) : STEPS.length - 1];
  const tone = (dir: string) => (dir === "down" ? ui().down : dir === "up" ? ui().up : ui().text);
  const labelStyle = () => ({ fontSize: 11, paddingVertical: 3, color: ui().muted, ...num });
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot ranges</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Move the pointer over the chart to read a row" : chart() === "candles" ? "Each candle spans its day: wick low to high, body open to close" : "Each step starts where the total stood"}
      </Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="chart">
              <Segmented options={["candles", "bridge"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
              <Text style={{ fontSize: 13, color: ui().text, ...num }}>
                {chart() === "candles" ? `Rule high ← low · Bar close ← open` : `Bar end ← start · ${STEPS.length} steps`}
              </Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <If
            when={chart() === "candles"}
            else={
              <>
                <View class="flex-row items-baseline gap-3">
                  <View style={{ flex: 1, gap: 2 }}>
                    <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>Profit bridge</Text>
                    <Text style={{ fontSize: size.detail, color: ui().muted }}>{`${step().name} · millions`}</Text>
                  </View>
                  <Text style={{ fontSize: 20, fontWeight: "600", color: tone(step().kind), ...num }}>
                    {step().kind === "total" ? `$${step().end.toFixed(1)}M` : `${signed(step().end - step().start)}M`}
                  </Text>
                </View>
                <Plot
                  data={STEPS}
                  xScale={{ type: "band", paddingInner: 0.32, paddingOuter: 0.16 }}
                  yScale={{ type: "linear", domain: [0, 160] }}
                  colorScale={{ type: "ordinal", domain: ["total", "up", "down"], range: [ui().info, ui().up, ui().down] }}
                  accessibilityLabel={`Profit bridge: $142.7M revenue to $${STEPS[STEPS.length - 1].end.toFixed(1)}M net profit`}
                  accessibilityValue={{ text: hovered() == null ? undefined : `${step().name}: ${step().end.toFixed(1)}` }}
                  appearGeneration="bridge"
                  motion={{ appear: { duration: 800, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 320, damping: 32 } }}
                  crossLineClass="w-full h-full"
                  crossLineStyle={{ backgroundColor: tint(ui().text, 0.05) }}
                  onHoverChange={({ index }) => {
                    hovered.set(index);
                    log(index == null ? "null: left the chart" : `${index}: ${STEPS[index].name}, ${STEPS[index].start} → ${STEPS[index].end}`);
                  }}
                  style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
                >
                  <Grid y={[40, 80, 120]} style={{ stroke: tint(ui().text, 0.08) }} />
                  <Grid y={[0]} style={{ stroke: tint(ui().text, 0.2) }} />
                  {/* From each row's start to its end, colored by its kind. */}
                  <Bar
                    value="end"
                    base="start"
                    color="kind"
                    cornerRadii={2}
                    stagger={0.5}
                    inactiveOpacity={0.4}
                    label={(d: Step) => (d.kind === "total" ? d.end.toFixed(1) : signed(d.end - d.start))}
                    labelStyle={labelStyle()}
                  />
                  <PlotAxis placement="right" tickValues={[0, 40, 80, 120]} labelStyle={{ width: 36, paddingLeft: 8, textAlign: "right", fontSize: 11, color: ui().muted, ...num }} />
                  <PlotAxis format={(i) => STEPS[i].name} labelStyle={{ fontSize: 11, lineHeight: 14, paddingTop: 12, color: ui().muted }} />
                </Plot>
              </>
            }
          >
            <View class="flex-row items-baseline gap-3">
              <View style={{ flex: 1, gap: 2 }}>
                <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>NSH Labs</Text>
                <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>
                  {`Day ${candle().day + 1} · O ${candle().open.toFixed(2)} H ${candle().high.toFixed(2)} L ${candle().low.toFixed(2)}`}
                </Text>
              </View>
              <Text style={{ fontSize: 20, fontWeight: "600", color: tone(candle().dir), ...num }}>{candle().close.toFixed(2)}</Text>
            </View>
            <Plot
              data={CANDLES}
              xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15 }}
              yScale={{ type: "linear", domain: [LOW, HIGH] }}
              colorScale={{ type: "ordinal", domain: ["up", "down"], range: [ui().up, ui().down] }}
              accessibilityLabel={`NSH Labs, ${CANDLES.length} days: from ${CANDLES[0].open.toFixed(2)} to ${CANDLES[CANDLES.length - 1].close.toFixed(2)}`}
              accessibilityValue={{ text: hovered() == null ? undefined : `Day ${candle().day + 1}: open ${candle().open}, high ${candle().high}, low ${candle().low}, close ${candle().close}` }}
              appearGeneration="candles"
              motion={{ appear: { duration: 800, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 320, damping: 32 } }}
              crossLineClass="w-full h-full"
              crossLineStyle={{ backgroundColor: tint(ui().text, 0.06) }}
              onHoverChange={({ index }) => {
                hovered.set(index);
                const c = index == null ? undefined : CANDLES[index];
                log(c == null ? "null: left the chart" : `${index}: ${c.low} – ${c.high}, ${c.open} → ${c.close}`);
              }}
              style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
            >
              <Grid y={[LOW + (HIGH - LOW) / 3, LOW + ((HIGH - LOW) * 2) / 3]} style={{ stroke: tint(ui().text, 0.08) }} />
              {/* The wick, low to high, under the body, open to close. */}
              <Rule value="high" base="low" color="dir" stagger={0.5} inactiveOpacity={0.3} style={{ strokeWidth: 1 }} />
              <Bar value="close" base="open" color="dir" cornerRadii={1} stagger={0.5} inactiveOpacity={0.3} />
              <PlotAxis
                placement="right"
                tickValues={[LOW + (HIGH - LOW) / 3, LOW + ((HIGH - LOW) * 2) / 3]}
                format={(v) => v.toFixed(0)}
                labelStyle={{ paddingLeft: 8, fontSize: 11, color: ui().muted, ...num }}
              />
              <PlotAxis tickValues={[0, 11, 23]} format={(i) => `Day ${i + 1}`} labelStyle={{ fontSize: 11, lineHeight: 14, paddingTop: 12, color: ui().muted }} />
            </Plot>
          </If>
        </View>
      </Split>
    </View>
  );
}

mount(App);

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

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:

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>
СвойствоТипПо умолчаниюОписание
valuestring | (d, index) => numbery графикаГде заканчивается каждая линия. У строки без значения линии нет.
basenumber | string | (d, index) => number0Где начинается каждая линия: одно значение для всех строк (стебель lollipop от нулевой линии) либо поле или функция, дающие каждой строке своё (минимум свечи). Оно учитывается в домене.
scale"y" | "y2""y"Шкала y, вдоль которой идут её значения (Двойные оси).
staggernumber0При appear графика линии стартуют одна за другой, строка за строкой, в пределах этой доли его длительности (от 0 до 0.95), и каждая растёт от своего начала оставшееся время.
inactiveOpacitynumberнетНепрозрачность её линий, кроме строки под указателем, пока указатель находится над графиком, как у Bar.
colorstring | (d, index) => stringнетКатегория каждой линии, чей цвет берётся из colorScale графика: stroke линии, если ничто другое его не задаёт.

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

#Dot

el-plot-dots.tsx
el-plot-dots.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Dot, Grid, Plot, PlotAxis } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// Dots placed by two values of each row: a `Dot` per row at its `x` on a
// linear x scale and its `value` up the y axis. Its `color` names each
// row's category, which the plot's ordinal `colorScale` turns into the
// dot's fill; a stroke in the card's color rings each dot, so dots that
// overlap stay apart.
//
// The scatter is one session a dot, minutes in the app against trades
// placed, colored by platform. The bubbles are countries by income and
// life expectancy, each bubble's radius from its population through a
// square-root `rScale`, so its area is in proportion to it; the bigger
// bubbles draw first and the smaller stay in sight over them. The dots
// grow in one after another, the dot under the pointer (or the arrow
// keys) is ringed by its hover dot and read out in the header, and the
// others fade.
type Session = { minutes: number; trades: number; platform: "iOS" | "Android" | "Web" };
type Country = { name: string; gdp: number; life: number; pop: number; region: Region };
type Region = "Asia" | "Africa" | "Americas" | "Europe" | "Oceania";

/** A repeatable stream of numbers in [0, 1), so every visit shows the same chart. */
const random = (seed: number) => {
  let s = seed;
  return () => {
    s = (s * 16807) % 2147483647;
    return (s % 10000) / 10000;
  };
};
const PLATFORMS = ["iOS", "Android", "Web"] as const;
/** Fifteen sessions per platform around its own habits, in the order of their length. */
const SESSIONS: Session[] = (() => {
  const r = random(23);
  const centers = { iOS: [14, 6], Android: [9, 4], Web: [24, 10] } as const;
  return PLATFORMS.flatMap((platform) =>
    Array.from({ length: 15 }, () => {
      const [m, t] = centers[platform];
      const minutes = Math.max(1, Math.round(m + (r() - 0.5) * m * 1.1));
      const trades = Math.max(0, Math.round(t * (minutes / m) + (r() - 0.5) * 5));
      return { minutes, trades, platform };
    }),
  ).sort((a, b) => a.minutes - b.minutes);
})();

/** Countries' GDP per person (thousands of dollars), life expectancy (years) and population (millions), in the order of income. */
const COUNTRIES: Country[] = (
  [
    ["Ethiopia", 1.3, 66.6, 127, "Africa"],
    ["Nigeria", 1.6, 53.6, 224, "Africa"],
    ["India", 2.5, 70.8, 1430, "Asia"],
    ["Egypt", 3.5, 70.4, 113, "Africa"],
    ["Vietnam", 4.3, 74.6, 99, "Asia"],
    ["Indonesia", 4.9, 71.3, 278, "Asia"],
    ["South Africa", 6.0, 63.2, 60, "Africa"],
    ["Brazil", 10.0, 75.8, 216, "Americas"],
    ["China", 12.6, 78.6, 1410, "Asia"],
    ["Mexico", 13.9, 74.8, 129, "Americas"],
    ["South Korea", 33.1, 83.7, 52, "Asia"],
    ["Japan", 33.8, 84.7, 124, "Asia"],
    ["France", 44.5, 82.8, 68, "Europe"],
    ["United Kingdom", 48.9, 81.3, 68, "Europe"],
    ["Germany", 52.7, 81.4, 84, "Europe"],
    ["Australia", 64.7, 83.5, 27, "Oceania"],
    ["United States", 80.4, 77.5, 335, "Americas"],
  ] as const
).map(([name, gdp, life, pop, region]) => ({ name, gdp, life, pop, region }));
const REGIONS: Region[] = ["Asia", "Africa", "Americas", "Europe", "Oceania"];

const num = { fontVariant: ["tabular-nums" as const] };
/** The room under a plot for its x axis' labels: their 12 above a line of 14. */
const AXIS_BELOW = 12 + 14;
const axisLabel = () => ({ fontSize: 11, color: ui().muted, ...num });

function App() {
  const chart = signal<"scatter" | "bubbles">("scatter");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const height = desktop ? 220 : 200;
  const platformColors = () => [ui().accent, ui().info, ui().warn];
  const regionColors = () => [ui().info, ui().warn, ui().accent, ui().violet, ui().muted];
  const session = () => (hovered() == null || chart() !== "scatter" ? undefined : SESSIONS[hovered()!]);
  const country = () => (hovered() == null || chart() !== "bubbles" ? undefined : COUNTRIES[hovered()!]);
  // A ring around the dot under the pointer, in the text's color, a little out from its edge.
  const ring = () => ({ outlineWidth: 1.5, outlineOffset: 1.5, outlineColor: ui().text });
  const key = (color: string, label: string) => (
    <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
      <View style={{ width: 9, height: 9, borderRadius: 4.5, backgroundColor: color }} />
      <Text style={{ fontSize: 12, color: ui().muted }}>{label}</Text>
    </View>
  );
  const motion = { appear: { duration: 900, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 320, damping: 32 } } as const;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot dots</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Move the pointer over a dot to read it" : chart() === "scatter" ? "Each dot is a session, colored by platform" : "Each bubble's area is a country's population"}
      </Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="chart">
              <Segmented options={["scatter", "bubbles"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
              <Text style={{ fontSize: 13, color: ui().text, ...num }}>
                {chart() === "scatter" ? `Dot x minutes · value trades · ${SESSIONS.length} rows` : `Dot r pop · rScale sqrt · ${COUNTRIES.length} rows`}
              </Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <If
            when={chart() === "scatter"}
            else={
              <>
                <View class="flex-row items-baseline gap-3">
                  <View style={{ flex: 1, gap: 2 }}>
                    <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{country()?.name ?? "Income and lifespan"}</Text>
                    <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>
                      {country() ? `${country()!.region} · ${country()!.pop.toLocaleString("en-US")}M people` : "GDP per person against life expectancy"}
                    </Text>
                  </View>
                  <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{country() ? `${country()!.life.toFixed(1)} yrs` : ""}</Text>
                </View>
                <View class="flex-row flex-wrap gap-x-3 gap-y-1">{REGIONS.map((r, i) => key(regionColors()[i], r))}</View>
                <Plot
                  data={COUNTRIES}
                  x="gdp"
                  xScale={{ type: "linear", domain: [-10, 95] }}
                  yScale={{ type: "linear", domain: [48, 90] }}
                  colorScale={{ type: "ordinal", domain: REGIONS, range: regionColors().map((c) => tint(c, 0.75)) }}
                  accessibilityLabel={`${COUNTRIES.length} countries by GDP per person and life expectancy, sized by population`}
                  accessibilityValue={{ text: country() && `${country()!.name}: $${country()!.gdp}k per person, ${country()!.life} years` }}
                  appearGeneration="bubbles"
                  motion={motion}
                  onHoverChange={({ index }) => {
                    hovered.set(index);
                    const c = index == null ? undefined : COUNTRIES[index];
                    log(c == null ? "null: left the chart" : `${index}: ${c.name}, $${c.gdp}k, ${c.life} yrs, ${c.pop}M`);
                  }}
                  style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
                >
                  <Grid x={[20, 40, 60, 80]} y={[60, 70, 80]} style={{ stroke: tint(ui().text, 0.08) }} />
                  {/* A bubble's area is its population: radii from 3 to the largest's, over 0 to the most people. */}
                  <Dot
                    value="life"
                    r="pop"
                    rScale={{ type: "sqrt", range: [3, desktop ? 26 : 18] }}
                    color="region"
                    stagger={0.5}
                    inactiveOpacity={0.25}
                    style={{ stroke: ui().card.backgroundColor, strokeWidth: 1 }}
                    hoverDotClass="rounded-full"
                    hoverDotStyle={ring()}
                  />
                  <PlotAxis placement="right" tickValues={[60, 70, 80]} format={(v) => `${v}`} labelStyle={{ ...axisLabel(), width: 36, paddingLeft: 8, textAlign: "right" }} />
                  <PlotAxis tickValues={[0, 20, 40, 60, 80]} format={(v) => `$${v}k`} labelStyle={{ ...axisLabel(), lineHeight: 14, paddingTop: 12 }} />
                </Plot>
              </>
            }
          >
            <View class="flex-row items-baseline gap-3">
              <View style={{ flex: 1, gap: 2 }}>
                <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{session() ? `${session()!.platform} session` : "Sessions"}</Text>
                <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>
                  {session() ? `${session()!.minutes} minutes in the app` : `${SESSIONS.length} sessions · minutes against trades`}
                </Text>
              </View>
              <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{session() ? `${session()!.trades} trades` : ""}</Text>
            </View>
            <View class="flex-row flex-wrap gap-x-3 gap-y-1">{PLATFORMS.map((p, i) => key(platformColors()[i], p))}</View>
            <Plot
              data={SESSIONS}
              x="minutes"
              xScale={{ type: "linear", domain: [0, 42] }}
              yScale={{ type: "linear", domain: [0, 22] }}
              colorScale={{ type: "ordinal", domain: [...PLATFORMS], range: platformColors() }}
              accessibilityLabel={`${SESSIONS.length} sessions by minutes in the app and trades placed, longest on the web`}
              accessibilityValue={{ text: session() && `${session()!.platform}: ${session()!.minutes} minutes, ${session()!.trades} trades` }}
              appearGeneration="scatter"
              motion={motion}
              onHoverChange={({ index }) => {
                hovered.set(index);
                const s = index == null ? undefined : SESSIONS[index];
                log(s == null ? "null: left the chart" : `${index}: ${s.platform}, ${s.minutes} min, ${s.trades} trades`);
              }}
              style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
            >
              <Grid x={[10, 20, 30, 40]} y={[5, 10, 15, 20]} style={{ stroke: tint(ui().text, 0.08) }} />
              <Grid y={[0]} style={{ stroke: tint(ui().text, 0.2) }} />
              <Dot
                value="trades"
                r={desktop ? 4 : 4.5}
                color="platform"
                stagger={0.6}
                inactiveOpacity={0.3}
                style={{ stroke: ui().card.backgroundColor, strokeWidth: 1.5 }}
                hoverDotClass="rounded-full"
                hoverDotStyle={ring()}
              />
              <PlotAxis placement="right" tickValues={[5, 10, 15, 20]} labelStyle={{ ...axisLabel(), width: 36, paddingLeft: 8, textAlign: "right" }} />
              <PlotAxis tickValues={[0, 10, 20, 30, 40]} format={(v) => `${v} min`} labelStyle={{ ...axisLabel(), lineHeight: 14, paddingTop: 12 }} />
            </Plot>
          </If>
        </View>
      </Split>
    </View>
  );
}

mount(App);

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

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 до наибольшего значения, если шкала не задаёт свой. Крупные точки рисуются первыми, так что мелкие остаются видны поверх них:

<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:

<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>
СвойствоТипПо умолчаниюОписание
valuestring | (d, index) => numbery графикаГде находится каждая точка вдоль оси значений: вверх по оси y или вдоль x на band-шкале y. У строки без значения точки нет.
rnumber | string | (d, index) => number3Радиус точек в пунктах либо поле или функция, дающие каждой строке значение, которое rScale превращает в радиус (без шкалы значение и есть радиус). У строки, значение которой отсутствует, точки нет.
rScale{ type: "sqrt"; domain?: [number, number]; range: [number, number] }нетКак значения r становятся радиусами: их квадратные корни на domain (от 0 до наибольшего значения, если домена нет) отображаются на range радиусов, так что при диапазоне от 0 площадь каждой точки пропорциональна её значению. Значения вне домена принимают крайние значения диапазона.
staggernumber0При появлении графика (appear) точки начинают расти одна за другой, строка за строкой, в течение этой доли времени (от 0 до 0.95); 0 заставляет их расти одновременно.
inactiveOpacitynumberнетПрозрачность остальных точек, пока одна из них под указателем или выбрана клавишами: они плавно гаснут и возвращаются вместе с наведением.
colorstring | (d, index) => stringнетКатегория каждой точки, её цвет берётся из colorScale графика: fill точки там, где ничего другого не задано (Цвета по категориям).
hitRadiusnumber8На каком расстоянии за краем точки её всё ещё находит указатель: из точек в пределах этого расстояния указатель находит ту, чей центр ближе всего к нему (ячейки Вороного), так что маленький пузырь поверх большого находится вокруг собственного центра. Строка dot plot находится из любой точки вдоль неё, если радиус не меньше ширины строки.
scale"y" | "y2""y"Шкала y, вдоль которой идут её значения (Две оси); точки на band-шкале y располагают свои значения вдоль x.
hoverDotClass, hoverDotStylestring, 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

el-plot-cells.tsx
el-plot-cells.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Cell, Plot, PlotAxis } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// Heatmaps: a `Cell` per row in the band of its `x` category across and
// of its `y` category down, both band scales, colored by its `value`
// through the plot's `colorScale`.
//
// The returns are a stock's months: the months across in the order of the
// x scale's `domain` (this year's months to come stay blank), the years
// down in the order the rows bring them, a loss red and a gain green along
// a diverging ramp about 0, its domain the largest move either way, each
// return written in its cell, white on the dark ones (`data-dark:`). The
// activity is a market's trades per hour of a week along a sequential
// ramp from the card's line color to the accent; the hour of its lunch
// break has no rows and stays blank. The cells grow in one after another,
// the cell under the pointer (or the arrow keys, which step across the
// grid) is ringed and read out in the header, and the others fade.
type Month = { year: string; month: string; ret: number };
type Hour = { day: string; hour: string; trades: number };

/** A repeatable stream of numbers in [0, 1), so every visit shows the same chart. */
const random = (seed: number) => {
  let s = seed;
  return () => {
    s = (s * 16807) % 2147483647;
    return (s % 10000) / 10000;
  };
};
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** Four years of a stock's monthly returns in percent, this year's up to October. */
const RETURNS: Month[] = (() => {
  const r = random(41);
  const drift: Record<string, number> = { "2023": 2.4, "2024": 1.9, "2025": 0.6, "2026": 1.2 };
  return Object.keys(drift).flatMap((year) =>
    MONTHS.slice(0, year === "2026" ? 10 : 12).map((month) => ({ year, month, ret: Math.round((drift[year] + (r() - 0.5) * 12) * 10) / 10 })),
  );
})();
const HOURS = ["9:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00"];
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"];
/** A week of a market's trades per hour, in thousands: busy at the open and the close, closed from 12:00 to 13:00. */
const ACTIVITY: Hour[] = (() => {
  const r = random(17);
  const shape = [1, 0.62, 0.5, null, 0.58, 0.55, 0.86];
  return DAYS.flatMap((day, d) =>
    HOURS.flatMap((hour, h) => (shape[h] == null ? [] : [{ day, hour, trades: Math.round(40 * shape[h]! * (0.92 + d * 0.04) * (0.9 + r() * 0.2) * 10) / 10 }])),
  );
})();

const num = { fontVariant: ["tabular-nums" as const] };
const signed = (v: number) => `${v < 0 ? "−" : "+"}${Math.abs(v).toFixed(1)}%`;
const axisLabel = () => ({ fontSize: 11, color: ui().muted, ...num });
/** The largest move either way: the ends of the diverging scale's own domain. */
const REACH = Math.max(...RETURNS.map((m) => Math.abs(m.ret)));

function App() {
  const chart = signal<"returns" | "activity">("returns");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const month = () => (hovered() == null || chart() !== "returns" ? undefined : RETURNS[hovered()!]);
  const hour = () => (hovered() == null || chart() !== "activity" ? undefined : ACTIVITY[hovered()!]);
  // A ring around the cell under the pointer, in the text's color, a little out from its edge.
  const ring = () => ({ outlineWidth: 1.5, outlineOffset: 1.5, outlineColor: ui().text });
  const returnsRamp = () => [ui().down, ui().line, ui().up];
  const activityRamp = () => [ui().line, ui().accent];
  const key = (colors: string[], labels: string[]) => (
    <View style={{ gap: 4 }}>
      <View style={{ height: 6, borderRadius: 3, backgroundImage: `linear-gradient(to right, ${colors.join(", ")})` }} />
      <View class="flex-row justify-between">
        {labels.map((l) => (
          <Text style={{ fontSize: 11, color: ui().muted, ...num }}>{l}</Text>
        ))}
      </View>
    </View>
  );
  const motion = { appear: { duration: 900, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 380, damping: 34 } } as const;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot cells</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Move the pointer over a cell to read it" : chart() === "returns" ? "Each cell is a month's return" : "Each cell is an hour's trades"}
      </Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="chart">
              <Segmented options={["returns", "activity"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
              <Text style={{ fontSize: 13, color: ui().text, ...num }}>
                {chart() === "returns" ? `colorScale diverging · ${RETURNS.length} rows` : `colorScale sequential · ${ACTIVITY.length} rows`}
              </Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <If
            when={chart() === "returns"}
            else={
              <>
                <View class="flex-row items-baseline gap-3">
                  <View style={{ flex: 1, gap: 2 }}>
                    <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{hour() ? `${hour()!.day} ${hour()!.hour}` : "Trades per hour"}</Text>
                    <Text style={{ fontSize: size.detail, color: ui().muted }}>{hour() ? "thousand trades" : "A week, closed from 12:00 to 13:00"}</Text>
                  </View>
                  <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{hour() ? `${hour()!.trades.toFixed(1)}k` : ""}</Text>
                </View>
                {key(activityRamp(), ["Fewer", "More"])}
                <Plot
                  data={ACTIVITY}
                  xScale={{ type: "band", domain: HOURS, paddingInner: 0.12 }}
                  yScale={{ type: "band", domain: DAYS, paddingInner: 0.12 }}
                  colorScale={{ type: "sequential", range: activityRamp() }}
                  accessibilityLabel="Trades per hour over a week, busiest at the open"
                  accessibilityValue={{ text: hour() && `${hour()!.day} ${hour()!.hour}: ${hour()!.trades} thousand trades` }}
                  appearGeneration="activity"
                  motion={motion}
                  onHoverChange={({ index }) => {
                    hovered.set(index);
                    const h = index == null ? undefined : ACTIVITY[index];
                    log(h == null ? "null: left the chart" : `${index}: ${h.day} ${h.hour}, ${h.trades}k`);
                  }}
                  style={{ height: DAYS.length * 30, marginLeft: 36, marginTop: 20 }}
                >
                  <Cell x="hour" y="day" value="trades" cornerRadii={4} stagger={0.6} inactiveOpacity={0.4} hoverCellClass="rounded-[5px]" hoverCellStyle={ring()} />
                  <PlotAxis placement="top" labelStyle={{ ...axisLabel(), lineHeight: 14, paddingBottom: 6 }} />
                  <PlotAxis placement="left" labelStyle={{ ...axisLabel(), width: 36, paddingRight: 8, textAlign: "right" }} />
                </Plot>
              </>
            }
          >
            <View class="flex-row items-baseline gap-3">
              <View style={{ flex: 1, gap: 2 }}>
                <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{month() ? `${month()!.month} ${month()!.year}` : "Monthly returns"}</Text>
                <Text style={{ fontSize: size.detail, color: ui().muted }}>{month() ? "the month's return" : "Red for a loss, green for a gain"}</Text>
              </View>
              <Text style={{ fontSize: 20, fontWeight: "600", color: month() ? (month()!.ret < 0 ? ui().down : ui().up) : ui().text, ...num }}>
                {month() ? signed(month()!.ret) : ""}
              </Text>
            </View>
            {key(returnsRamp(), [signed(-REACH), "0", signed(REACH)])}
            <Plot
              data={RETURNS}
              xScale={{ type: "band", domain: MONTHS, paddingInner: 0.1 }}
              yScale={{ type: "band", paddingInner: 0.12 }}
              colorScale={{ type: "diverging", range: returnsRamp() }}
              accessibilityLabel={`Monthly returns since 2023, ${RETURNS.filter((m) => m.ret > 0).length} of ${RETURNS.length} months up`}
              accessibilityValue={{ text: month() && `${month()!.month} ${month()!.year}: ${signed(month()!.ret)}` }}
              appearGeneration="returns"
              motion={motion}
              onHoverChange={({ index }) => {
                hovered.set(index);
                const m = index == null ? undefined : RETURNS[index];
                log(m == null ? "null: left the chart" : `${index}: ${m.month} ${m.year}, ${signed(m.ret)}`);
              }}
              style={{ height: 4 * 30, marginLeft: 36, marginTop: 20 }}
            >
              <Cell
                x="month"
                y="year"
                value="ret"
                cornerRadii={3}
                stagger={0.6}
                inactiveOpacity={0.4}
                label={(d: Month) => (d.ret < 0 ? `−${(-d.ret).toFixed(1)}` : d.ret.toFixed(1))}
                labelClass="text-black/70 data-dark:text-white/90"
                labelStyle={{ fontSize: 10, ...num }}
                hoverCellClass="rounded-[4px]"
                hoverCellStyle={ring()}
              />
              <PlotAxis placement="top" labelStyle={{ ...axisLabel(), lineHeight: 14, paddingBottom: 6 }} />
              <PlotAxis placement="left" labelStyle={{ ...axisLabel(), width: 36, paddingRight: 8, textAlign: "right" }} />
            </Plot>
            <Text style={{ fontSize: 12, color: tint(ui().text, 0.5) }}>Each return shows where its cell has room for it.</Text>
          </If>
        </View>
      </Split>
    </View>
  );
}

mount(App);

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

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 занимает середину градиента, какие бы активы ни были в портфеле:

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>
СвойствоТипПо умолчаниюОписание
xstring | (d, index) => stringнетСтолбец каждой строки: поле (его текст или число) либо функция, возвращающая категорию. Ячейка строки попадает в полосу этой категории на шкале x; у строки без категории ячейки нет.
ystring | (d, index) => stringнетРяд каждой строки, как x на шкале y.
valuestring | (d, index) => numberнетЧисло каждой ячейки, из которого sequential или diverging colorScale делает её цвет. Строка без числа получает цвет unknown шкалы, а если его нет, у неё нет ячейки.
colorstring | (d, index) => stringнетКатегория каждой ячейки, её цвет берётся из ordinal colorScale (Цвета по категориям).
cornerRadiinumber | { topLeft?, topRight?, bottomRight?, bottomLeft? }0Радиус каждого угла ячеек.
staggernumber0При appear графика ячейки начинают расти одна за другой, строка за строкой, в течение этой доли времени (от 0 до 0.95), каждая из своей середины до конца; 0 заставляет их расти одновременно.
inactiveOpacitynumberнетПрозрачность остальных ячеек и их подписей, пока одна из них под указателем или выбрана клавишами: они плавно гаснут и возвращаются вместе с наведением.
labelstring | (d, index) => stringнетЧасть label: текст в середине каждой ячейки, поле (его текст или число) либо функция, возвращающая текст, который форматирует приложение. Каждая подпись показывается там, где её рассчитанный размер помещается в ячейку, поэтому на узком графике видны одни цвета.
labelClass, labelStylestring, StyleProp<TextStyle>нетШрифт, размер и цвет подписей. Подпись на ячейке с тёмным цветом colorScale получает атрибут data-dark (где светлый текст читается лучше), поэтому data-dark:text-white делает его там светлым.
hoverCellClass, hoverCellStylestring, StyleProp<ViewStyle>нетЧасть hoverCell: представление размером с ячейку под указателем или выбранную клавишами, поверх неё; rounded-sm outline-2 outline-foreground обводит её кольцом. Оно движется за указателем в том же кадре и скользит на пружине pointer, когда его двигают клавиши.

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

#Pie и Arc

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

el-plot-pie.tsx
el-plot-pie.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Arc, Pie, Plot } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui, wide } from "./palette";

// A donut built from the plot's parts: a `Pie` of the rows' shares, each
// slice in a series color of the theme (`fill-chart-*`, a class per row),
// 2-point gaps between them (the pad is as wide at the hole as at the rim),
// the largest share first, clockwise from 12 o'clock. The slice under the
// pointer lifts and the others fade, natively; its value is in the hole and
// its legend row stands out while the others fade too. The legend's values
// and shares are tabular and at the trailing edge. A breakdown with nothing
// in it keeps a faint ring, an `Arc`, so the chart still reads as one.
type Part = { name: string; revenue: number };
type Breakdown = "Region" | "Channel" | "None";
type Shape = "donut" | "pie";

const BREAKDOWNS: Record<Breakdown, Part[]> = {
  Region: [
    { name: "North America", revenue: 482 },
    { name: "EMEA", revenue: 312 },
    { name: "APAC", revenue: 268 },
    { name: "Latin America", revenue: 104 },
    { name: "Other", revenue: 74 },
  ],
  Channel: [
    { name: "Direct", revenue: 540 },
    { name: "Partners", revenue: 330 },
    { name: "Marketplace", revenue: 250 },
    { name: "Resellers", revenue: 120 },
  ],
  None: [
    { name: "North America", revenue: 0 },
    { name: "EMEA", revenue: 0 },
  ],
};

/** The theme's series colors, as a slice's fill and a legend key's
 * background. */
const FILLS = ["fill-chart-1", "fill-chart-2", "fill-chart-3", "fill-chart-4", "fill-chart-5"];
const KEYS = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"];

const money = (k: number) => (k >= 1000 ? `$${(k / 1000).toFixed(2)}M` : `$${k}k`);
const percent = (share: number) => `${(share * 100).toFixed(1)}%`;
const num = { fontVariant: ["tabular-nums" as const], textAlign: "right" as const };

function App() {
  const breakdown = signal<Breakdown>("Region");
  const shape = signal<Shape>("donut");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const parts = () => BREAKDOWNS[breakdown()];
  const total = () => parts().reduce((sum, p) => sum + p.revenue, 0);
  const shown = () => {
    const at = hovered();
    return at == null ? undefined : parts()[at];
  };
  const share = (p: Part) => (total() > 0 ? p.revenue / total() : 0);
  // The ring's size: its rim is half of it, the gaps 2 points wide there.
  const ring = desktop ? 200 : 184;
  const donut = () => shape() === "donut";
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>pie</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Move the pointer over a slice to read it" : "Pick a breakdown to draw other slices"}
      </Text>

      <Split
        width={520}
        inspector={
          <>
            <Panel title="shape · data">
              <Segmented options={["donut", "pie"] as const} value={shape()} onChange={(s) => shape.set(s)} />
              <Segmented
                options={["Region", "Channel", "None"] as const}
                value={breakdown()}
                onChange={(b) => (breakdown.set(b), hovered.set(null))}
              />
              <Text style={{ fontSize: 13, color: ui().text }}>
                {`${parts().filter((p) => p.revenue > 0).length} slices · ${money(total())} in all`}
              </Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 16 }}>
          <View class="flex-row items-baseline gap-3">
            <View style={{ flex: 1, gap: 2 }}>
              <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>Revenue</Text>
              <Text style={{ fontSize: size.detail, color: ui().muted }}>{`Q3 2026 · by ${breakdown() === "Channel" ? "channel" : "region"}`}</Text>
            </View>
            <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{money(total())}</Text>
          </View>

          <View style={{ flexDirection: wide() ? "row" : "column", alignItems: "center", gap: wide() ? 28 : 18 }}>
            <Plot
              data={parts()}
              accessibilityLabel={
                total() > 0
                  ? `Revenue by ${breakdown().toLowerCase()}, ${money(total())} in all; largest ${parts()[0].name} at ${percent(share(parts()[0]))}`
                  : "Revenue: nothing this quarter"
              }
              accessibilityValue={{ text: shown() && `${shown()!.name}: ${money(shown()!.revenue)}, ${percent(share(shown()!))}` }}
              // Motion of the app's: the ring sweeps in for each breakdown,
              // the hole opens and closes between donut and pie, the slice
              // under the pointer lifts on the spring; reduced motion shows
              // it all at once.
              appearGeneration={["Region", "Channel", "None"].indexOf(breakdown())}
              motion={{ appear: { duration: 900, easing: "easeOut" }, update: { duration: 450, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 420, damping: 40 } }}
              onHoverChange={({ index }) => {
                hovered.set(index);
                const p = index == null ? undefined : parts()[index];
                const line = p ? `${index}: ${p.name}, ${money(p.revenue)} (${percent(share(p))})` : "null: off the slices";
                events.set([line, ...events()].slice(0, 5));
              }}
              style={{ width: ring, height: ring }}
            >
              {/* Nothing to split: a faint ring keeps the chart's shape. */}
              <If when={total() === 0}>
                <Arc innerRadius={donut() ? "62%" : 0} outerRadius={ring / 2 - 8} style={{ fill: tint(ui().text, 0.06) }} />
              </If>
              <Pie
                value="revenue"
                innerRadius={donut() ? "62%" : 0}
                // The rim 8 points in, room for the lift.
                outerRadius={ring / 2 - 8}
                padAngle={2 / (ring / 2 - 8)}
                sliceClass={(_, i) => FILLS[i % FILLS.length]}
                hoverLift={6}
                hoverDim={0.64}
              />
              {/* The hovered part, or the total, in the hole: it fades with
                  the hole opening and closing. */}
              <View
                class="transition-opacity duration-300 motion-reduce:transition-none"
                style={{ pointerEvents: "none", position: "absolute", left: 0, top: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center", gap: 4, opacity: donut() ? 1 : 0 }}
              >
                <Text style={{ fontSize: size.detail, color: ui().muted }}>{shown()?.name ?? "Total"}</Text>
                <Text style={{ fontSize: 22, fontWeight: "600", color: ui().text, ...num, textAlign: "center" }}>
                  {money(shown()?.revenue ?? total())}
                </Text>
                <Text style={{ fontSize: size.detail, color: ui().muted, ...num, textAlign: "center" }}>
                  {shown() ? percent(share(shown()!)) : `${parts().filter((p) => p.revenue > 0).length} parts`}
                </Text>
              </View>
            </Plot>

            {/* The legend: a key in each slice's color, the value and the
                share at the trailing edge; the hovered part's row stands out. */}
            <View style={{ alignSelf: "stretch", flex: wide() ? 1 : undefined, gap: 2 }}>
              <View style={{ flexDirection: "row", gap: 12, paddingHorizontal: 8, paddingBottom: 4 }}>
                <View style={{ width: 8 }} />
                <Text style={{ flex: 1, fontSize: 11, color: ui().faint }}>{breakdown() === "Channel" ? "Channel" : "Region"}</Text>
                <Text style={{ fontSize: 11, color: ui().faint, ...num }}>Revenue</Text>
                <Text style={{ width: 52, fontSize: 11, color: ui().faint, ...num }}>Share</Text>
              </View>
              {parts().map((p, i) => (
                <View
                  class="transition-opacity duration-150 motion-reduce:transition-none"
                  style={{
                    flexDirection: "row",
                    alignItems: "center",
                    gap: 12,
                    height: size.rowSm,
                    paddingHorizontal: 8,
                    borderRadius: 6,
                    backgroundColor: hovered() === i ? tint(ui().text, 0.05) : undefined,
                    opacity: hovered() == null || hovered() === i ? 1 : 0.45,
                  }}
                >
                  <View class={KEYS[i % KEYS.length]} style={{ width: 8, height: 8, borderRadius: 2 }} />
                  <Text style={{ numberOfLines: 1, flex: 1, fontSize: size.label - 1, color: ui().text }}>{p.name}</Text>
                  <Text style={{ fontSize: size.label - 1, color: ui().text, ...num }}>{money(p.revenue)}</Text>
                  <Text style={{ width: 52, fontSize: size.label - 1, color: ui().muted, ...num }}>{percent(share(p))}</Text>
                </View>
              ))}
            </View>
          </View>
        </View>
      </Split>
    </View>
  );
}

mount(App);
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>
  );
}
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())),
        )
}
MarkPropRustТипПо умолчаниюОписание
Pievalue.value(..)keyof T | (d: T, index: number) => numbery графикаСколько стоит каждая строка. У строки, которая стоит ноль, меньше нуля или не имеет числа, сектора нет.
Pie, ArcstartAngle, endAngle.start_angle(..), .end_angle(..)number (радианы)0, 2 * Math.PIГде начинается первый сектор и где заканчивается последний (концы дуги): радианы от 12 часов по часовой стрелке. Если endAngle меньше startAngle, секторы круговой диаграммы раскладываются в обратную сторону.
PiepadAngle.pad_angle(..)number (радианы)0Угол между соседними секторами у внешнего края. Каждый зазор сохраняет эту ширину (около padAngle * outerRadius пунктов) на любом радиусе, его края параллельны, так что в центре или у отверстия он так же широк, как у внешнего края. Сектор, слишком узкий, чтобы дойти до центра, заканчивается там, где сходятся его края. У круговой диаграммы из одного сектора зазора нет.
Pie, ArcinnerRadius, outerRadius.inner_radius(..), .outer_radius(..)number | `${number}%` 0, "100%"Радиусы отверстия и внешнего края: в пунктах или в процентах от наибольшего круга, который вмещает график (половина его меньшей стороны).
Pie, ArccornerRadius.corner_radius(..)number0Радиус углов каждого сектора (и дуги) в пунктах, как у arc.cornerRadius в d3: каждый угол — окружность, касающаяся края и внешнего края или отверстия, включая параллельные края зазоров. Он не превышает половины толщины кольца, что даёт дуге круглые концы, а на секторе, слишком узком для двух целых углов, они встречаются посередине. У целого кольца остаются только круглые края.
PiesliceClass.slice_class(..)string | (d: T, index: number) => stringнетКлассы каждого сектора (fill-*, stroke-*): одни и те же для всех секторов или функция от строки сектора и её индекса, вызываемая один раз для каждой строки при изменении строк или функции.
PiesliceStyle.slice_style(..)StyleProp<ViewStyle>нетСтиль каждого сектора поверх его классов (fill, stroke, strokeWidth).
PiehoverLift.hover_lift(..)number0Насколько сектор под указателем выступает над остальными, в пунктах: на пружине pointer графика, плавно появляясь и исчезая вместе с наведением.
PiehoverDim.hover_dim(..)number (от 0 до 1)0Насколько остальные секторы затухают, пока один под указателем, как доля их непрозрачности, с затуханием наведения.
Piecolor.color(..)keyof T | (d: T, index: number) => stringнетКатегория каждого сектора, его цвет из colorScale графика: fill сектора там, где цвет ничем не задан (Цвета по категориям).
Pielabel.label(..)string | (d: T, index: number) => stringнетТекст рядом с каждым сектором: поле (его число) или функция, возвращающая текст, который форматирует приложение. У строки с пустым текстом и у сектора уже полуградуса текста нет.
PielabelClass, labelStyle.label_class(..), .label_style(..)string, StyleProp<TextStyle>нетЧасть label: текст снаружи кольца на конце его линии-выноски, центрированный по ней; на правой половине диаграммы он стоит после линии, на левой — перед ней. Его padding — это расстояние от конца линии.
PielabelGap.label_gap(..)number15Насколько далеко за внешним краем стоят подписи, в пунктах.
PielabelLineClass, 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%" или свободное место вокруг графика. Собственное содержимое приложения у сектора — это элемент, наложенный на график, как и центр кольцевой диаграммы.

<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"
/>

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

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

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"(обязательно)Вид шкалы.
domainstring[]нетКатегории по порядку. Категория, записанная дважды, сохраняет своё первое место.
rangestring[](обязательно)Цвета по порядку (CSS-цвета); категория i получает цвет i % range.length.
unknownstringнетЦвет каждой категории вне домена; если его нет, каждая получает следующий цвет.

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

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

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

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 выровненная по опорной точке)Значения двух концов градиента.
pivotnumber0Только для diverging: значение среднего цвета градиента.
rangestring[](обязательно)Цвета вдоль градиента от нижнего края к верхнему (CSS-цвета), смешиваемые между собой в OKLab.
unknownstringнетЦвет строки без значения; если его нет, у такой строки нет ячейки.

#RadialLine и RadialGrid

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

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

Радиальная линия обводится с помощью 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 — процент от стороны графика, немного больше половины доли внешнего радиуса) отмечает место за краем, а название растёт от него в сторону от центра: справа от него в правой половине, слева от него в левой, над ним вверху и под ним внизу, как выше.

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 ничего не сдвигает.

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>
  );
}
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(..)number24Ширина каждого узла в пунктах.
nodePadding.node_padding(..)number8Промежуток между узлами столбца в пунктах; меньше в столбце, слишком тесном для него.
nodeAlign.node_align(..)"justify" | "left" | "right" | "center""justify"В каком столбце находится каждый узел, как у d3-sankey: его глубина от первого столбца ("left"), как можно правее, насколько позволяют его потоки ("right"), его глубина, причём узел, который только начинает потоки, ставится рядом со своими целями ("center"), или его глубина, причём узлы, в которых потоки заканчиваются, помещаются в последний столбец ("justify").
iterations.iterations(..)number6Сколько проходов подтягивают узлы к их потокам.
valueScale.value_scale(..)"linear" | "sqrt""linear"Высоты и ширины пропорционально значениям или их квадратным корням.
minLinkWidth.min_link_width(..)number1Наименьшая толщина, с которой рисуется лента, в пунктах, чтобы малый поток оставался видимым.
nodeCornerRadius.node_corner_radius(..)number0Радиус углов каждого узла в пунктах.
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 графика не используются.

el-plot-treemap.tsx
el-plot-treemap.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Plot, Treemap } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// A `Treemap` of the plot's rows: each row a node, its `id` and the
// `parent` it names making the tree, each leaf's area its `value` and each
// group's the sum of its leaves'. The squarified tiles go largest first
// from the top left (`sort="value"`), or in the rows' order
// (`sort="data"`); the gaps between siblings are `paddingInner` per level,
// and each group's name sits in a band at its top (`paddingTop`).
//
// The market map colors each stock by its change of the day along a
// diverging `colorScale` (losses red, gains green, flat neutral) and
// centers its ticker on it. The budget has three levels, a line of 0 and
// one below 0, which take no area: a treemap's areas are shares of a
// whole. The node under the pointer (or the arrow keys) is outlined and
// read out, the others fading.
type Node = { id: string; parent?: string; name: string; value?: number; chg?: number };

const MARKET: Node[] = [
  { id: "tech", name: "Technology" },
  { id: "comm", name: "Communication" },
  { id: "fin", name: "Financials" },
  { id: "health", name: "Healthcare" },
  ...(
    [
      ["NVDA", "tech", 4300, 2.4],
      ["MSFT", "tech", 3700, -0.6],
      ["AAPL", "tech", 3600, 0.4],
      ["AVGO", "tech", 1600, 3.1],
      ["ORCL", "tech", 720, -2.8],
      ["AMD", "tech", 380, 1.8],
      ["GOOGL", "comm", 3100, 1.1],
      ["META", "comm", 1800, -1.5],
      ["NFLX", "comm", 480, 0.2],
      ["BRK.B", "fin", 1050, 0.2],
      ["JPM", "fin", 830, 1.3],
      ["V", "fin", 670, -0.3],
      ["GS", "fin", 240, 2.7],
      ["LLY", "health", 820, -1.9],
      ["UNH", "health", 300, -4.1],
      ["JNJ", "health", 460, 0.4],
    ] as const
  ).map(([id, parent, value, chg]) => ({ id, parent, name: id, value, chg })),
];

/** A team's yearly budget in thousands: three levels, a line that is done (0) and one refunded (below 0). */
const BUDGET: Node[] = [
  { id: "eng", name: "Engineering" },
  { id: "ops", name: "Operations" },
  { id: "infra", parent: "eng", name: "Infrastructure" },
  { id: "product", parent: "eng", name: "Product" },
  { id: "cloud", parent: "infra", name: "Cloud", value: 420 },
  { id: "ci", parent: "infra", name: "CI runners", value: 96 },
  { id: "observability", parent: "infra", name: "Observability", value: 74 },
  { id: "salaries", parent: "product", name: "Contractors", value: 310 },
  { id: "design", parent: "product", name: "Design tools", value: 38 },
  { id: "research", parent: "product", name: "User research", value: 0 },
  { id: "office", parent: "ops", name: "Office", value: 180 },
  { id: "travel", parent: "ops", name: "Travel", value: 64 },
  { id: "legal", parent: "ops", name: "Legal", value: -12 },
];

const num = { fontVariant: ["tabular-nums" as const] };
const pct = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v).toFixed(1)}%`;
/** A node's value: its own, or its leaves' sum (what the treemap gives `valueLabel`). */
const total = (rows: Node[], id: string): number => {
  const children = rows.filter((r) => r.parent === id);
  if (children.length === 0) return Math.max(0, rows.find((r) => r.id === id)?.value ?? 0);
  return children.reduce((s, c) => s + total(rows, c.id), 0);
};
const path = (rows: Node[], node: Node): string => {
  const parent = node.parent && rows.find((r) => r.id === node.parent);
  return parent ? `${path(rows, parent)} / ${node.name}` : node.name;
};

function App() {
  const chart = signal<"market" | "budget">("market");
  const sort = signal<"value" | "data">("value");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const rows = () => (chart() === "market" ? MARKET : BUDGET);
  const shown = () => (hovered() == null ? undefined : rows()[hovered()!]);
  const height = desktop ? 260 : 240;
  const motion = { appear: { duration: 800, easing: "easeOut" }, update: { duration: 500, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 380, damping: 36 } } as const;
  const outline = () => ({ borderRadius: 3, outlineWidth: 2, outlineColor: ui().text });
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot treemap</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Move the pointer over a tile or a group's name" : "Each tile's area is its share of the whole"}</Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="data">
              <Segmented options={["market", "budget"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
            </Panel>
            <Panel title="sort">
              <Segmented options={["value", "data"] as const} value={sort()} onChange={(s) => sort.set(s)} />
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <View class="flex-row items-baseline gap-3">
            <View style={{ flex: 1, gap: 2 }}>
              <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>
                {shown() ? path(rows(), shown()!) : chart() === "market" ? "Largest stocks by sector" : "Budget by team"}
              </Text>
              <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>
                {chart() === "market" ? "Area: market cap · color: change of the day" : "Thousands of dollars · a line of 0 and a refund take no area"}
              </Text>
            </View>
            <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>
              {shown() ? (chart() === "market" && shown()!.chg != null ? pct(shown()!.chg!) : `$${total(rows(), shown()!.id)}k`) : ""}
            </Text>
          </View>
          <Plot
            data={rows()}
            colorScale={
              chart() === "market"
                ? { type: "diverging", domain: [-3, 3], range: [ui().down, tint(ui().text, 0.1), ui().up] }
                : { type: "ordinal", domain: ["eng", "ops"], range: [ui().info, ui().warn] }
            }
            accessibilityLabel={chart() === "market" ? "Market map of 16 stocks in 4 sectors" : "Budget of two teams in three levels"}
            accessibilityValue={{ text: shown() && path(rows(), shown()!) }}
            appearGeneration={chart()}
            motion={motion}
            onHoverChange={({ index }) => {
              hovered.set(index);
              const n = index == null ? undefined : rows()[index];
              log(n == null ? "null: left the map" : `${index}: ${path(rows(), n)}`);
            }}
            style={{ height }}
          >
            <Treemap
              id="id"
              parent="parent"
              value="value"
              sort={sort()}
              colorValue={chart() === "market" ? "chg" : undefined}
              color={chart() === "budget" ? (d: Node) => (d.id === "ops" || d.parent === "ops" ? "ops" : "eng") : undefined}
              paddingInner={chart() === "market" ? [3, 1] : [4, 2, 1]}
              paddingOuter={chart() === "market" ? 0 : [2, 1]}
              paddingTop={18}
              cornerRadii={3}
              stagger={0.4}
              inactiveOpacity={0.35}
              groupClass={chart() === "budget" ? "fill-foreground/5" : undefined}
              label={(d: Node) => d.name}
              valueLabel={(d: Node, value: number) => (d.chg != null ? pct(d.chg) : `$${value}k`)}
              labelPlacement={chart() === "market" ? "center" : "start"}
              labelClass="p-1.5 text-foreground data-dark:text-white"
              labelStyle={{ fontSize: 11, fontWeight: "600" }}
              valueLabelStyle={{ fontSize: 10, fontWeight: "400", opacity: 0.85, ...num }}
              groupLabel="name"
              groupLabelClass="px-1.5 text-muted-foreground"
              groupLabelStyle={{ fontSize: 10.5, lineHeight: 18 }}
              hoverStyle={outline()}
            />
          </Plot>
        </View>
      </Split>
    </View>
  );
}

mount(App);
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>
  );
}
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 графика начинаются на столько же отступя от него (половина высоты подписи для деления на конце), так что каждая подпись остаётся в пределах графика, на своей линии сетки.

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 (Двойные оси).
labelPlacement.label_placement(..)"outside" | "inside""outside"Где находятся подписи: за краем, в пространстве, которое приложение оставляет вокруг графика, где они не пересекаются с метками; или поверх графика вдоль края, так что график сохраняет полный размер (у левого или правого края каждая подпись находится с верхней стороны своей линии сетки).
ticks.ticks(..)number10 на 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) и появляются с затуханием при появлении графика.

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 его текстов (Две оси).

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

ПолеТипПо умолчаниюОписание
textstringЧто он читает.
xnumberГде он находится по оси x: значение шкалы x или индекс строки на шкале point или band (0.5 — ровно посередине между первыми двумя строками).
ynumberГде находится его верхний край по оси y: значение шкалы y.
align"left" | "center" | "right""left"Как он располагается по x относительно своей точки: начинается в ней, центрируется на ней или заканчивается в ней.
class, stylestring, 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 сообщает, на что указывают тексты.

#Анимация

Анимация задаётся приложением и по умолчанию отсутствует: всё показывается сразу. motion принимает тайминги nsh ({ duration, easing, delay } и { stiffness, damping, mass }):

КлючТипЧто движется
appeartimingМетки прорисовываются с appearGeneration: линии и области — от передней кромки, столбцы вырастают от основания (по очереди с stagger столбца); точки вырастают из ничего (по очереди с stagger точки); ячейки вырастают из середин (по очереди с stagger ячейки); кольцо круговой диаграммы и дуги разворачивается по часовой стрелке от своего начала; радиальная линия вырастает от внутреннего радиуса вместе со своими точками; узлы и ленты диаграммы Санкея — от передней кромки, а их подписи появляются с затуханием.
updatetimingМетки переходят к новой форме, когда приложение её меняет: точки — к новым значениям и размерам, точка новой строки вырастает, а точка исчезнувшей уменьшается; ячейки — к новым цветам (смешение в OKLab) и полосам, ячейка новой строки вырастает, а ячейка исчезнувшей уменьшается; радиусы круговой диаграммы и дуги, так что отверстие кольцевой диаграммы раскрывается и закрывается, и их углы, так что шкала-индикатор или сектор разворачивается к новому значению (arcTween из d3: движутся углы, а не контур; новый сектор раскрывается оттуда, где заканчивается предыдущий; изменение во время движения продолжается оттуда, где нарисовано); точки радиальной линии — к новым значениям, углам и радиусам (новая строка вырастает от внутреннего радиуса), а вместе с ними кольца радиальной сетки; и домен линейной шкалы, будь то domain приложения или экстент значений меток по мере поступления строк, плавно переходит от старого к новому, а вместе с ним метки, линии сетки, PlotAxis и PlotLabel; и узлы, ленты и подписи диаграммы Санкея — от раскладки старых потоков к новой, причём изменение во время движения продолжается оттуда, где они нарисованы.
entertimingЧасти подсветки плавно появляются, когда указатель заходит на график.
exittimingОни плавно исчезают после того, как он уходит.
pointerspringОни плавно переходят от одной строки к следующей, когда клавиши или программа чтения с экрана перемещают подсветку; сектор круговой диаграммы при этом приподнимается.

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

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

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

Это собственная анимация графика, тайминги того, что движется в диаграмме: обрезка его меток и части его подсветки. Входы, выходы и привлекающие внимание анимации представления — это пресеты и ключевые кадры createMotion, которые приложение по-прежнему может проигрывать на представлении графика или на окружающих его элементах.

#PlotAppearScope

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

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().

#Клавиатура

Интерактивный график — это остановка для 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 элемента, переходят к следующей и предыдущей строке, на обращённой шкале — тоже в порядке строк.

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

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

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

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 оставляет несколько столбцов узкими в широкой карточке.

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}: он рисует свои метки и не следует за указателем.

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

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

Общие сведения о подписях и ролях см. в разделе Доступность.

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

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