Skip to content

Оси ​

Типы осей: category (бэнды), number, time, log. Связка по position: bottom/top — ось X, left/right — ось Y.

Вид по умолчанию ​

Из коробки оси не шумят, и два направления делят работу: у оси категорий остаётся линия и нет сетки, ось значений линию не рисует — её читают по пунктирной сетке. Тики выключены на обеих. В горизонтальном графике ось значений — горизонтальная, поэтому пунктир вместе с ней разворачивается вертикально.

Вся эта обвязка светло-серая — токен темы axisColor, переопределяется на каждой оси через line.stroke, tick.stroke и gridLine.stroke. Дефолты лежат под вашими опциями, так что всё возвращается по запросу: tick: { enabled: true }, line: { enabled: true }, gridLine: { enabled: true, lineDash: [] } — сплошная сетка вдоль категорий.

Линия, тики и сетка ​

У линии оси те же три опции стиля, что и у сетки — цвет, толщина и пунктир, а у тиков рядом настраиваются длина и цвет:

js
axes: [
  {
    position: 'bottom',
    line: { stroke: '#0f766e', width: 2, lineDash: [6, 3] },
    tick: { enabled: true, size: 10, width: 2, color: '#0f766e' },
    gridLine: { stroke: '#e2e8f0', width: 1, lineDash: [2, 4] },
  },
],

line.lineDash и gridLine.lineDash записываются как массивы штрихов: [штрих, пробел] в пикселях; пустой массив [] рисует сплошную линию, даже если тема задала пунктир. tick.size — длина засечки, она всегда рисуется наружу от области построения, а tick.color — алиас tick.stroke: любой из них задаёт цвет засечек и выигрывает у темы (tickColor).

Две оси значений ​

Величины разного масштаба — евро и проценты, запросы и задержка — плохо уживаются на одной шкале: меньшая прижимается к нулевой линии. Объявите вторую ось значений на противоположной стороне, а keys на каждой оси скажет, какие серии она несёт:

js
series: [
  { type: 'bar', xField: 'month', yField: 'revenue', name: 'Revenue' },
  { type: 'line', xField: 'month', yField: 'margin', name: 'Margin' },
],
axes: [
  { type: 'category', position: 'bottom' },
  { type: 'number', position: 'left', keys: ['revenue'], title: { text: 'Revenue, k€' } },
  { type: 'number', position: 'right', keys: ['margin'], title: { text: 'Margin, %' } },
],
ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Revenue and Margin' },
    subtitle: { text: 'thousands of euro and per cent, on their own scales' },
    series: [
      { type: 'bar', xField: 'month', yField: 'revenue', name: 'Revenue' },
      { type: 'line', xField: 'month', yField: 'margin', name: 'Margin' },
    ],
    axes: [
      { type: 'category', position: 'bottom' },
      { type: 'number', position: 'left', keys: ['revenue'], title: { text: 'Revenue, k€' } },
      { type: 'number', position: 'right', keys: ['margin'], label: { format: ',.1f' }, title: { text: 'Margin, %' } },
    ],
  };
}
ts
export function getData() {
  return [
    { month: 'Jan', revenue: 420, margin: 12.4 },
    { month: 'Feb', revenue: 465, margin: 13.1 },
    { month: 'Mar', revenue: 510, margin: 12.8 },
    { month: 'Apr', revenue: 495, margin: 14.6 },
    { month: 'May', revenue: 580, margin: 15.2 },
    { month: 'Jun', revenue: 640, margin: 16.9 },
    { month: 'Jul', revenue: 705, margin: 16.1 },
    { month: 'Aug', revenue: 690, margin: 17.4 },
  ];
}

В keys перечисляются поля значений — yField, а у многополевых серий их low/high и OHLC-поля; id серии тоже подходит, и так две серии по одному полю расходятся на разные оси. Всё, что не забрала ни одна ось, достаётся первой оси без keys — поэтому график с одной осью значений ведёт себя как раньше.

Дальше каждая ось масштабируется только по своим сериям и сама подбирает круглые границы; скрытие серии через легенду пересчитывает только её ось. Две сетки никогда не совпали бы, поэтому сетку сохраняет только первая ось значений — второй её можно вернуть через gridLine: { enabled: true }.

В горизонтальном графике всё то же самое, только оси значений — bottom и top. По первой оси значений по-прежнему читаются аннотации, подпись значения у перекрестия и окно зума по Y.

Временная ось ​

time принимает Date, timestamp или ISO-строку; тики встают на календарные границы, формат подписи зависит от шага (часы → дни → месяцы → годы).

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Metric over Time' },
    series: [{ type: 'line', xField: 'date', yField: 'value', name: 'Value', marker: { enabled: false } }],
    axes: [
      { type: 'time', position: 'bottom' },
      { type: 'number', position: 'left' },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  const start = Date.UTC(2025, 0, 1);
  const day = 24 * 60 * 60 * 1000;
  const values = [41, 43, 47, 45, 49, 53, 51, 56, 58, 55, 61, 64, 62, 67, 70, 68, 73, 71, 76, 79, 77, 82, 85, 83, 88, 86, 91, 94, 92, 97];
  return values.map((value, index) => ({ date: new Date(start + index * 3 * day), value }));
}

Столбцы на временной оси ​

Столбцы, диапазоны, ящики и свечи встают на ось time так же, как на полосную, — разница в том, что ось расставляет их по настоящему расстоянию, и месяц без данных оставляет пустое место, а не исчезает:

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Sales by month' },
    subtitle: { text: 'February has no data — a continuous axis keeps its place' },
    series: [
      { type: 'bar', xField: 'date', yField: 'plan', name: 'Plan' },
      { type: 'bar', xField: 'date', yField: 'actual', name: 'Actual' },
    ],
    axes: [
      { type: 'time', position: 'bottom' },
      { type: 'number', position: 'left', title: { text: 'M₽' } },
    ],
    legend: { position: 'bottom' },
  };
}
ts
/** Monthly sales — February is missing, and a continuous axis leaves its place empty. */
export function getData() {
  const month = (index: number) => new Date(Date.UTC(2025, index, 1));
  return [
    { date: month(0), plan: 120, actual: 108 },
    { date: month(2), plan: 130, actual: 141 },
    { date: month(3), plan: 135, actual: 129 },
    { date: month(4), plan: 140, actual: 152 },
    { date: month(5), plan: 145, actual: 138 },
    { date: month(6), plan: 150, actual: 163 },
  ];
}

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

bandSpan перебивает это значение и задаётся в единицах оси — миллисекундах для временной, поэтому при зуме столбец продолжает накрывать свой период, чего ширина в пикселях не даёт:

ts
axes: [
  // часовые замеры с редкими пропусками: столбец шириной в час, что бы ни говорили пропуски
  { type: 'time', position: 'bottom', bandSpan: 60 * 60 * 1000 },
  { type: 'number', position: 'left' },
]

Та же опция есть у оси number, в её собственных единицах. По одной точке шаг не вывести — такой столбец получает десятую часть ширины области построения.

Когда даты стоят ровно — торговые сессии, недели без выходных — второй ответ даёт ось ordinal-time: полосы равной ширины с календарными подписями, то есть пропуски закрываются и расстояние ни на что не влияет.

Логарифмическая ось ​

log — для данных, растущих на порядки; тики на степенях base (по умолчанию 10).

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Exponential Growth' },
    subtitle: { text: 'logarithmic Y axis' },
    series: [{ type: 'line', xField: 'year', yField: 'users', name: 'Users' }],
    axes: [
      { type: 'category', position: 'bottom' },
      { type: 'log', position: 'left' },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { year: '2018', users: 120 },
    { year: '2019', users: 540 },
    { year: '2020', users: 2400 },
    { year: '2021', users: 9800 },
    { year: '2022', users: 41000 },
    { year: '2023', users: 165000 },
    { year: '2024', users: 720000 },
    { year: '2025', users: 2900000 },
  ];
}

Шаг, который выдержит формат ​

Сколько тиков возьмёт ось значений, решает её длина — примерно по одному на 70 px, — а шаг после этого округляется до 1, 2 или 5 × 10ⁿ. Высокий график поэтому делится мелко: по 200 000 на четыре миллиона, и это нормально ровно до тех пор, пока формат не окажется грубее шага. Компактный формат без дробной части печатает «1M» для всего от миллиона до 1,9 — пять тиков подряд читаются одинаково, и шкала перестаёт говорить, где находится значение.

Поэтому ось number или log перечитывает собственные подписи, прежде чем их нарисовать. Там, где соседние совпали, она оставляет каждый n-й тик — с наименьшим n, при котором они различаются: шаг шкалы вырастает до того, который формат способен выдержать (200 000 → миллион), и сетка идёт за подписями, а не обгоняет их:

ts
axes: [
  { type: 'category', position: 'bottom' },
  {
    type: 'number',
    position: 'left',
    // «1M» от 1,0 до 1,9: ось сама останавливается на целых миллионах
    label: { formatter: ({ value }) => Intl.NumberFormat('ru', { notation: 'compact' }).format(Number(value)) },
  },
];

Значения, перечисленные в interval.values, остаются как есть — это те тики, которые вы попросили, — и так же остаётся ось с label.avoidCollisions: false. Чтобы сохранить мелкий шаг, дайте формату точность ему под стать (maximumFractionDigits: 1): ось отступает, только когда подписи не оставляют ей выбора. Категориальные оси это не трогает: две строки могут честно носить одно имя.

Иерархические категории ​

grouped-category: значения данных — массивы [группа, элемент] (уровней может быть и больше); под подписями элементов появляется строка групп с разделителями:

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Revenue by Product Category' },
    subtitle: { text: '$B, grouped categories year → quarter' },
    series: [
      { type: 'bar', xField: 'period', yField: 'iphone', name: 'iPhone' },
      { type: 'bar', xField: 'period', yField: 'mac', name: 'Mac' },
      { type: 'bar', xField: 'period', yField: 'services', name: 'Services' },
    ],
    axes: [
      { type: 'grouped-category', position: 'bottom' },
      { type: 'number', position: 'left' },
    ],
  };
}
ts
export function getData() {
  return [
    { period: ['2018', 'Q1'], iphone: 140, mac: 16, services: 20 },
    { period: ['2018', 'Q2'], iphone: 124, mac: 20, services: 30 },
    { period: ['2018', 'Q3'], iphone: 112, mac: 20, services: 36 },
    { period: ['2018', 'Q4'], iphone: 118, mac: 24, services: 36 },
    { period: ['2019', 'Q1'], iphone: 124, mac: 18, services: 26 },
    { period: ['2019', 'Q2'], iphone: 108, mac: 20, services: 40 },
    { period: ['2019', 'Q3'], iphone: 96, mac: 22, services: 42 },
    { period: ['2019', 'Q4'], iphone: 104, mac: 22, services: 40 },
  ];
}

В горизонтальных чартах ось категорий вертикальная, и колонка групп с разделителями появляется слева от подписей элементов:

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Revenue by Product Category' },
    subtitle: { text: '$B, grouped categories year → quarter' },
    series: [
      { type: 'bar', xField: 'period', yField: 'iphone', name: 'iPhone', direction: 'horizontal' },
      { type: 'bar', xField: 'period', yField: 'mac', name: 'Mac', direction: 'horizontal' },
      { type: 'bar', xField: 'period', yField: 'services', name: 'Services', direction: 'horizontal' },
    ],
    axes: [
      { type: 'grouped-category', position: 'left' },
      { type: 'number', position: 'bottom' },
    ],
  };
}
ts
export function getData() {
  return [
    { period: ['2018', 'Q1'], iphone: 140, mac: 16, services: 20 },
    { period: ['2018', 'Q2'], iphone: 124, mac: 20, services: 30 },
    { period: ['2018', 'Q3'], iphone: 112, mac: 20, services: 36 },
    { period: ['2018', 'Q4'], iphone: 118, mac: 24, services: 36 },
    { period: ['2019', 'Q1'], iphone: 124, mac: 18, services: 26 },
    { period: ['2019', 'Q2'], iphone: 108, mac: 20, services: 40 },
    { period: ['2019', 'Q3'], iphone: 96, mac: 22, services: 42 },
    { period: ['2019', 'Q4'], iphone: 104, mac: 22, services: 40 },
  ];
}

Столько ярусов, сколько уровней в кортеже ​

Кортеж не ограничен двумя элементами: каждый, кроме последнего, получает свой ярус. ['2024', 1, 'Q1'] подписывает деления кварталами, над ними ставит ряд полугодий, а над ними — ряд годов: самый верхний уровень дальше всех от графика, как в шапке сводной таблицы. Разделитель принадлежит самому верхнему уровню, на котором он есть, — граница года рисуется один раз и проходит через все ярусы.

Группа — это соседние категории с равными значениями, а не с равным текстом: null и 'null', 1 и '1' остаются двумя группами ровно так же, как они остаются двумя категориями. Два объекта Date на один и тот же момент — одна группа.

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Revenue by quarter' },
    subtitle: { text: '$B, three levels on the axis: year → half → quarter' },
    series: [{ type: 'bar', xField: 'period', yField: 'revenue', name: 'Revenue', cornerRadius: 2 }],
    axes: [
      {
        type: 'grouped-category',
        position: 'bottom',
        // the formatter gets the raw level value: the halves arrive as 1 and 2
        groupLabel: { formatter: ({ value, level }) => (level === 0 ? `FY ${String(value)}` : `H${String(value)}`) },
      },
      { type: 'number', position: 'left', label: { format: '$,d' } },
    ],
    legend: { enabled: false },
  };
}
ts
/** Three levels on the axis: year → half (raw number) → quarter. */
export function getData() {
  return [
    { period: ['2023', 1, 'Q1'], revenue: 117 },
    { period: ['2023', 1, 'Q2'], revenue: 94 },
    { period: ['2023', 2, 'Q3'], revenue: 82 },
    { period: ['2023', 2, 'Q4'], revenue: 120 },
    { period: ['2024', 1, 'Q1'], revenue: 126 },
    { period: ['2024', 1, 'Q2'], revenue: 91 },
    { period: ['2024', 2, 'Q3'], revenue: 88 },
    { period: ['2024', 2, 'Q4'], revenue: 134 },
  ];
}

Оформление и формат ярусов групп ​

У ярусов свой блок опций — groupLabel: свой шрифт, цвет и формат, потому что надпись группы отвечает не на тот вопрос, что деление под ней. Форматтеру приходит сырое значение своего уровня, номер яруса и диапазон делений, которые группа накрывает: у группы нет индекса деления — она стоит над их рядом:

js
axes: [
  {
    type: 'grouped-category',
    groupLabel: {
      fontSize: 12,
      color: '#334155',
      // уровень 0 — самый верхний ярус
      formatter: ({ value, level }) => (level === 0 ? `FY ${value}` : `H${value}`),
    },
  },
];

groupLabel.format — сериализуемая половина того же самого ('%b %Y', ',.0f'), применяется к значению уровня. Без того и другого группа печатается так же, как число на делении, — с сокращением миллионов и тысяч.

label.formatter остаётся за подписями элементов: ему приходит весь кортеж и индекс деления, так что каждый ярус форматируется под свой вопрос. groupLabel: { enabled: false } убирает ярусы совсем — подписи элементов остаются, а ось перестаёт резервировать место под группы.

Подписи всегда влезают ​

Подпись ставится по якорю: подпись тика центрируется на тике, подпись значения висит у своего столбца. Поэтому обе выходят за прямоугольник графика — на половину ширины крайней подписи оси, на всю ширину подписи справа от самого длинного столбца. Раскладка измеряет этот вылет и вычитает его из области построения, так что край канвы ничего не обрезает: место уступает график, а не подпись.

Это место делится с зонами осей, а не прибавляется к ним: вылет подписи на 12 px влево ничего не стоит, если ось Y и так занимает там 40 px. Поэтому эффект виден только там, где он нужен: процентная ось, последний тик которой стоит у правого края, или горизонтальные столбцы, у которых подписи значений уходят вслед за столбцами.

Вписывание идёт в область, оставшуюся после padding, заголовка с подзаголовком и легенды, — так что ваши отступы остаются вашими, подписи в них не залезают.

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Value Labels on Bars' },
    subtitle: { text: "outer ('top') and inner ('inner-top') placements" },
    series: [
      {
        type: 'bar',
        xField: 'team',
        yField: 'done',
        name: 'Done',
        label: { enabled: true, placement: 'inner-top', fontWeight: 'bold' },
      },
      {
        type: 'bar',
        xField: 'team',
        yField: 'planned',
        name: 'Plan',
        label: { enabled: true, placement: 'top' },
      },
    ],
  };
}
ts
export function getData() {
  return [
    { team: 'Alpha', done: 34, planned: 42 },
    { team: 'Beta', done: 27, planned: 30 },
    { team: 'Gamma', done: 41, planned: 38 },
    { team: 'Delta', done: 22, planned: 35 },
  ];
}

Полярные графики вписываются так же: радиус сетки подбирается так, чтобы названия категорий по кругу остались внутри области, а длинное название с одной стороны сдвигает центр, а не ужимает всю паутину. Там, где спицы встают плотно, подписи, которые налезли бы друг на друга, снимаются — сама сетка остаётся целой (см. Радар). У круговой диаграммы и пончика радиус так же ограничивают выносные подписи.

Подписи, которые не влезают ​

На горизонтальной оси у подписи ровно один шаг места. Когда названия длиннее, по умолчанию они прореживаются: остаётся каждая вторая — или каждая третья, — и та рисуется целиком. Для дат и чисел это читается хорошо: пропущенные значения достраиваются в уме. Для категорий — плохо: пропущенное название значит пропущенную категорию.

Ось grouped-category прореживает не по оси целиком, а по прогонам: каждый прогон категорий оставляет столько подписей, сколько помещается между его собственными разделителями, и отбирает их от середины к краям. Подпись, которая вылезла бы за свой прогон, скрывается, а не рисуется поверх соседней группы, — и прогон уже собственной подписи остаётся без неё: за него говорит имя группы. Наклонённые подписи прореживаются по оси целиком — см. Наклон на оси с группами.

label.overflow: 'ellipsis' выбирает другой размен: на оси остаются все подписи, и каждая обрезается по месту между тиками, а на месте обрезки встаёт label.ellipsis — по умолчанию '..', можно задать '…'. Ничто больше не налезает на соседа и на линию тика между ними.

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Revenue by department' },
    subtitle: { text: '$M, long category names cut to the room between the ticks' },
    series: [{ type: 'bar', xField: 'department', yField: 'revenue', name: 'Revenue', cornerRadius: 2 }],
    axes: [
      {
        type: 'grouped-category',
        position: 'bottom',
        // every name stays on the axis: the ones that do not fit are cut instead of dropped
        label: { overflow: 'ellipsis' },
      },
      { type: 'number', position: 'left', label: { format: '$,d' } },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { department: ['Retail', 'Kitchen appliances'], revenue: 48 },
    { department: ['Retail', 'Home entertainment'], revenue: 36 },
    { department: ['Retail', 'Personal computing'], revenue: 29 },
    { department: ['Wholesale', 'Building materials'], revenue: 41 },
    { department: ['Wholesale', 'Industrial fittings'], revenue: 33 },
    { department: ['Wholesale', 'Agricultural supplies'], revenue: 22 },
  ];
}

На оси grouped-category ярусы групп живут по тому же правилу: надпись держат в границах прогона категорий, над которым она стоит, — и она не доходит до разделителей по краям. groupLabel.maxWidth сужает её ещё, знак обрезки берётся из label.ellipsis; имя, от которого обрезка оставила бы один знак, скрывается. Имена групп живут внутри своих прогонов так же, как подписи: имя шире прогона скрывается, а не перешагивает разделитель к соседу.

label.maxWidth — самостоятельное ограничение: действует независимо от тесноты, а на вертикальной оси заодно решает, сколько канвы подписи вправе отнять у графика — обрезанные длинные названия слева перестают двигать область построения вправо.

js
axes: [
  { type: 'category', position: 'bottom', label: { overflow: 'ellipsis' } },
  // у левой оси нет шага, в который надо вписаться: границу задаёт maxWidth
  { type: 'category', position: 'left', label: { maxWidth: 90, ellipsis: '…' } },
];

Подписи под углом ​

Третий размен — повернуть подписи. label.rotation задаёт наклон в градусах по часовой стрелке: -45 на нижней оси кладёт названия по диагонали вверх вправо, каждое заканчивается у своего тика; 45 наклоняет их в другую сторону; -90 ставит на торец. Все названия остаются целыми, и ни одно не пропадает. 'auto' отдаёт выбор угла самой оси — см. Угол подбирается сам.

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Deliveries by destination' },
    subtitle: { text: 'thousand parcels, every city name kept on the axis' },
    series: [{ type: 'bar', xField: 'destination', yField: 'parcels', name: 'Parcels', cornerRadius: 2 }],
    axes: [
      {
        type: 'category',
        position: 'bottom',
        // tilted names clear each other by a line of text rather than by their length,
        // so eight of them fit where four would level out the axis
        label: { rotation: -45 },
      },
      { type: 'number', position: 'left' },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { destination: 'Nizhny Novgorod', parcels: 184 },
    { destination: 'Rostov-on-Don', parcels: 162 },
    { destination: 'Yekaterinburg', parcels: 147 },
    { destination: 'Novosibirsk', parcels: 131 },
    { destination: 'Chelyabinsk', parcels: 118 },
    { destination: 'Krasnoyarsk', parcels: 104 },
    { destination: 'Vladivostok', parcels: 92 },
    { destination: 'Kaliningrad', parcels: 77 },
  ];
}

Наклонённые подписи лежат параллельными полосами, поэтому сталкиваются они не длиной, а расстоянием между тиками: полосы расходятся, как только между ними помещается строка текста, — около 15 px при 45°, 11 px на торце, независимо от того, что в подписях написано. Поэтому наклон сохраняет те названия, которые ровной оси пришлось бы проредить.

Место, которое просит повёрнутый текст, ось резервирует: она вырастает на глубину наклонённой строки, а если крайнее название вылезает за край области построения, компоновка находит место и там — так же, как для ровной подписи. То есть наклон стоит высоты, а не названий: дороже всего -90 — на всю длину самого длинного названия, дешевле всего пологие углы. Если одно длинное имя забирает больше, чем того стоит, ограничьте его через label.maxWidth.

ts
axes: [
  // восемь названий городов на одной оси: ни одного скрытого, ни одного обрезанного
  { type: 'category', position: 'bottom', label: { rotation: -45 } },
  { type: 'number', position: 'left' },
];

Наклон на оси с группами ​

Ось grouped-category прореживает подписи по прогонам: ровное название центрируется на своём тике и расходится в обе стороны, поэтому вылезшее за прогон читалось бы как соседское. Наклонённое так не умеет — оно заканчивается у собственного тика и указывает на него, — поэтому прогоны перестают решать, и ось прореживает так же, как любая другая. Возвращаются все названия, которым не хватало места в узком прогоне: группы шириной в день-другой, где ровная ось не подписала бы ни одной категории, получают свои имена.

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

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Revenue by team' },
    subtitle: { text: '$M per quarter — every team keeps its name, and the separators lean with them' },
    series: [{ type: 'bar', xField: 'team', yField: 'revenue', name: 'Revenue', cornerRadius: 2 }],
    axes: [
      {
        type: 'grouped-category',
        position: 'bottom',
        // a tilted name ends at its own tick, so it is never read as the quarter next door's
        label: { rotation: -45, formatter: ({ value }) => String((value as string[])[1]).replace(/ Q\d$/, '') },
      },
      { type: 'number', position: 'left', label: { format: '$,d' } },
    ],
    legend: { enabled: false },
  };
}
ts
const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];
const teams = ['Onboarding', 'Retention', 'Enterprise', 'Self-serve', 'Partnerships'];

export function getData() {
  const rows: Array<{ team: [string, string]; revenue: number }> = [];
  for (const [q, quarter] of quarters.entries()) {
    for (const [t, team] of teams.entries()) {
      rows.push({ team: [quarter, `${team} ${quarter}`], revenue: 40 + ((q * 5 + t * 7) % 9) * 11 });
    }
  }
  return rows;
}

Угол подбирается сам ​

label.rotation: 'auto' отдаёт решение оси. Пока все подписи помещаются ровно, они стоят ровно; как только одну из них пришлось бы скрыть — наклоняются, и настолько полого, насколько позволяет шаг: 30°, если названию хватает 30 px шага, затем 45°, 60° и на торец, если тики стоят ещё теснее. График, у которого данные набегают в течение дня, так и держит подписи ровными, пока может, и поворачивает их ровно настолько, насколько приходится.

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Building permits by district' },
    subtitle: { text: 'the axis picks the angle: level while the names fit, tilted the moment one would not' },
    series: [{ type: 'bar', xField: 'district', yField: 'permits', name: 'Permits', cornerRadius: 2 }],
    axes: [
      { type: 'category', position: 'bottom', label: { rotation: 'auto' } },
      { type: 'number', position: 'left' },
    ],
    legend: { enabled: false },
  };
}
ts
const districts = [
  'Basmanny',
  'Zamoskvorechye',
  'Krasnoselsky',
  'Meshchansky',
  'Presnensky',
  'Tagansky',
  'Tverskoy',
  'Khamovniki',
  'Yakimanka',
  'Arbat',
  'Sokolniki',
  'Maryina Roshcha',
];

export function getData() {
  return districts.map((district, index) => ({ district, permits: 96 - index * 6 + (index % 3) * 5 }));
}

Угол выбирается один раз на отрисовку и держится всю её: место, которое наклон просит по краям оси, и есть то, что двигает область построения, — а ось, отвечающая на собственный новый шаг более пологим углом, не остановилась бы ни на одном. Новые данные или новый размер решаются заново.

'auto' отступает там, где оси уже дан ответ на тесноту: label.overflow: 'ellipsis' вместо этого обрезает подписи, а label.avoidCollisions: false велит оставить их как есть. И то и другое сохраняет подписи ровными. Вертикальная ось читает подписи поперёк себя, по строке на каждую, — они не теснятся, и 'auto' их тоже не трогает.

Наклон — для подписей у оси: label.placement: 'inside' рисует их внутри графика ровно, как и раньше. label.overflow: 'ellipsis' наклонённой подписи тоже не по чему обрезать — соседа, на которого она налезла бы, у неё нет, — так что границу задаёт maxWidth.

Подписи внутри графика ​

label.placement: 'inside' переносит подписи тиков внутрь области построения, и ось не резервирует под них толщину. На вертикальной категорийной оси каждая подпись получает свою строку: строка прибавляется к зазору между бэндами (и одна резервируется над первым), поэтому paddingInner — или gap — продолжает означать ровно то же, что и везде: расстояние между элементами. Внутренние подписи рисуются поверх серий.

У внутренней подписи два своих расстояния, по 4 px: label.insideSpacing — отступ от оси внутрь области построения, и label.insideGap — зазор до своего элемента и до предыдущего (именно он задаёт высоту строки). label.spacing относится только к внешним подписям и сюда не достаёт.

label.insideAlign выбирает, где подпись стоит внутри этой строки: 'element' (по умолчанию) прижимает её к своему элементу на insideGap, 'gap' центрирует между двумя элементами.

Когда строки перестают влезать — много категорий на низкой оси — подписи прореживаются так же, как тесные подписи на горизонтальной оси: остаётся каждая k-я. label.avoidCollisions: false это отключает и рисует все. Под зазор бэнд отдаёт не больше 80 % шага, так что элемент виден всегда.

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Traffic by channel' },
    subtitle: { text: 'category labels sit above their bars' },
    series: [
      {
        type: 'bar',
        xField: 'channel',
        yField: 'share',
        name: 'Share',
        direction: 'horizontal',
        cornerRadius: 2,
        label: { enabled: true, placement: 'right', fontWeight: 'bold', formatter: ({ value }) => `${Math.round(value * 100)}%` },
      },
    ],
    axes: [
      {
        type: 'category',
        position: 'left',
        label: { placement: 'inside', fontWeight: 'bold', insideSpacing: 0, insideGap: 4 },
        line: { enabled: false },
      },
      {
        type: 'number',
        position: 'bottom',
        label: { format: '.0%' },
        gridLine: { lineDash: [2, 3] },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { channel: 'Organic search', share: 0.6 },
    { channel: 'Direct', share: 0.2 },
    { channel: 'Referral', share: 0.3 },
    { channel: 'Email', share: 0.1 },
    { channel: 'Social', share: 0.05 },
    { channel: 'Paid ads', share: 0.4 },
  ];
}

У grouped-category уровни расходятся: колонка групп остаётся снаружи и занимает своё место, подписи элементов уходят внутрь и встают над барами, а разделитель групп сдвигается выше подписей.

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Revenue share by channel' },
    subtitle: { text: 'group column outside, item labels inside' },
    series: [
      {
        type: 'bar',
        xField: 'channel',
        yField: 'revenue',
        name: 'Share',
        direction: 'horizontal',
        cornerRadius: 2,
        label: { enabled: true, placement: 'right', fontWeight: 'bold', formatter: ({ value }) => `${Math.round(value * 100)}%` },
      },
    ],
    axes: [
      {
        type: 'grouped-category',
        position: 'left',
        label: { placement: 'inside', fontWeight: 'bold' },
        line: { enabled: false },
      },
      {
        type: 'number',
        position: 'bottom',
        max: 1,
        label: { format: '.0%' },
        gridLine: { lineDash: [2, 3] },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { channel: ['Europe', 'Web'], revenue: 0.48 },
    { channel: ['Europe', 'Retail'], revenue: 0.31 },
    { channel: ['Europe', 'Partners'], revenue: 0.17 },
    { channel: ['Americas', 'Web'], revenue: 0.62 },
    { channel: ['Americas', 'Retail'], revenue: 0.24 },
    { channel: ['Americas', 'Partners'], revenue: 0.09 },
  ];
}

На горизонтальной оси подписи идут вдоль внутреннего края области построения, а на оси значений — над своей линией сетки.

CrossLines ​

Опорные линии и диапазоны в координатах оси — с подписями:

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Latency p95' },
    series: [{ type: 'line', xField: 'month', yField: 'latency', name: 'p95, ms' }],
    axes: [
      {
        type: 'category',
        position: 'bottom',
        crossLines: [{ type: 'range', range: ['Apr', 'May'], label: { text: 'incident' } }],
      },
      {
        type: 'number',
        position: 'left',
        crossLines: [{ value: 200, stroke: '#e5484d', label: { text: 'SLO 200 ms', color: '#e5484d' } }],
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { month: 'Jan', latency: 182 },
    { month: 'Feb', latency: 174 },
    { month: 'Mar', latency: 196 },
    { month: 'Apr', latency: 230 },
    { month: 'May', latency: 218 },
    { month: 'Jun', latency: 187 },
    { month: 'Jul', latency: 171 },
    { month: 'Aug', latency: 165 },
  ];
}

Полярные оси ​

Радар, роза Найтингейл и радиальные столбцы рисуются на паутине, а паутина говорит то же, что пара картезианских осей: вот категории, вот значения. Настройки она принимает парой, а не списком: angle — категории по ободу, radius — кольца значений:

js
axes: {
  angle: { title: { text: 'Месяц' }, gridLine: { lineDash: [3, 3] }, line: { stroke: '#64748b' } },
  radius: { title: { text: 'Инциденты' }, min: 0, max: 60, ringCount: 3, label: { format: ',.0f' } },
},
ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Polar axes' },
    series: [{ type: 'nightingale', angleField: 'month', radiusField: 'incidents', name: 'Incidents', fillOpacity: 0.7 }],
    axes: {
      angle: {
        title: { text: 'Month' },
        gridLine: { lineDash: [3, 3], opacity: 0.5 },
        line: { stroke: '#64748b' },
      },
      radius: {
        title: { text: 'Incidents' },
        min: 0,
        max: 60,
        ringCount: 3,
        label: { format: ',.0f', fontWeight: 'bold' },
      },
    },
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { month: 'Jan', incidents: 14 },
    { month: 'Feb', incidents: 11 },
    { month: 'Mar', incidents: 17 },
    { month: 'Apr', incidents: 9 },
    { month: 'May', incidents: 13 },
    { month: 'Jun', incidents: 21 },
    { month: 'Jul', incidents: 18 },
    { month: 'Aug', incidents: 12 },
  ];
}

Сетка внутри паутины и линии осей вокруг неё — разные вещи, и настраиваются они по-разному. angle.gridLine — спицы, radius.gridLine — кольца: это фон, и выглядят они как сетка в теме — пунктиром, приглушённо, позади данных. Линии осей — это angle.line, обод, замыкающий паутину, и radius.line, вертикаль, вдоль которой читаются значения колец: у каждой свои цвет, толщина и штрих, обе сплошные и обе рисуются по умолчанию — там, где тема вообще рисует линии осей. Внешнее кольцо уступает ободу, так что по одной окружности они не проходят. Пустой lineDash делает линию сетки сплошной там, где тема ставит пунктир, а enabled: false убирает любую из четырёх линий.

Шкала значений подписана от центра наружу, вместе с самим центром: там шкала и начинается — нулём или порогом min, если он задан. Подписи принимают format или formatter, а заголовки стоят снаружи графика: заголовок категорий — под ним, заголовок значений — вдоль левого края. Место под них отнимается до подгонки сетки, поэтому заголовок никогда не перекрывает подпись.

Радиальные столбцы переворачивают раскладку — их категории становятся кольцами, а значения спицами, — но опции следуют смыслу, а не форме: angle по-прежнему про категории, radius — про значения. Столбцы проходят только часть круга, поэтому обод там — решение автора: его рисует angle.line: { enabled: true }; radius.line же — та линия, от которой столбцы растут, и она на месте, как и везде.

ОпцияТипПо умолчаниюОписание
angle.gridLineenabled, stroke, width, lineDash, opacityтема, пунктирспицы
angle.lineenabled, stroke, width, lineDashвкл. (обод)обод вокруг паутины
angle.labelenabled, шрифт, format, formatterвкл.названия категорий
angle.titleenabled, text, шрифт—заголовок под графиком
radius.gridLineкак вышетема, пунктиркольца
radius.lineкак вышевкл.вертикаль, вдоль которой читаются значения
radius.labelenabled, шрифт, format, formatterвкл.значения колец
radius.titleenabled, text, шрифт—заголовок вдоль левого края
radius.min / maxnumberпо даннымграницы шкалы значений
radius.nicebooleantrueокруглять границы до целых шагов
radius.ringCountnumber4сколько колец у шкалы значений

Все опции разом ​

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

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

/** Every polar axis option on a rose, each one set away from its default. */
export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Every axis option — rose' },
    series: [{ type: 'nightingale', angleField: 'month', radiusField: 'incidents', name: 'Incidents', fillOpacity: 0.55 }],
    legend: { enabled: false },
    axes: {
      angle: {
        // the spokes
        gridLine: { enabled: true, stroke: '#0ea5e9', width: 1, lineDash: [1, 3], opacity: 0.7 },
        // the rim around the web
        line: { enabled: true, stroke: '#0f172a', width: 2, lineDash: [8, 3] },
        // the category names
        label: {
          enabled: true,
          fontSize: 12,
          fontFamily: 'system-ui, sans-serif',
          fontWeight: 'bold',
          color: '#0f172a',
          formatter: ({ value }) => String(value).toUpperCase(),
        },
        title: { enabled: true, text: 'Month', fontSize: 14, fontFamily: 'system-ui, sans-serif', fontWeight: 'normal', color: '#0ea5e9' },
      },
      radius: {
        // bounds of the value scale, taken from the options rather than the data
        min: 0,
        max: 45,
        nice: false,
        ringCount: 3,
        // the rings
        gridLine: { enabled: true, stroke: '#f43f5e', width: 1, lineDash: [], opacity: 0.25 },
        // the vertical the ring values are read along
        line: { enabled: true, stroke: '#f43f5e', width: 2, lineDash: [4, 2] },
        // the ring values
        label: {
          enabled: true,
          fontSize: 11,
          fontFamily: 'system-ui, sans-serif',
          fontWeight: 'bold',
          color: '#f43f5e',
          format: ',.0f',
        },
        title: {
          enabled: true,
          text: 'Incidents',
          fontSize: 14,
          fontFamily: 'system-ui, sans-serif',
          fontWeight: 'normal',
          color: '#f43f5e',
        },
      },
    },
  };
}
ts
export function getData() {
  return [
    { month: 'Jan', incidents: 12 },
    { month: 'Feb', incidents: 7 },
    { month: 'Mar', incidents: 19 },
    { month: 'Apr', incidents: 25 },
    { month: 'May', incidents: 34 },
    { month: 'Jun', incidents: 41 },
    { month: 'Jul', incidents: 28 },
    { month: 'Aug', incidents: 16 },
  ];
}
ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

/** Every polar axis option on a radar, each one set away from its default. */
export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Every axis option — radar' },
    series: [{ type: 'radar-area', angleField: 'skill', radiusField: 'score', name: 'Score', fillOpacity: 0.15 }],
    legend: { enabled: false },
    axes: {
      angle: {
        // the spokes
        gridLine: { enabled: true, stroke: '#0ea5e9', width: 1, lineDash: [1, 3], opacity: 0.7 },
        // the rim around the web
        line: { enabled: true, stroke: '#0f172a', width: 2, lineDash: [8, 3] },
        // the category names
        label: {
          enabled: true,
          fontSize: 12,
          fontFamily: 'system-ui, sans-serif',
          fontWeight: 'bold',
          color: '#0f172a',
          formatter: ({ value, index }) => `${index + 1}. ${String(value)}`,
        },
        title: { enabled: true, text: 'Skill', fontSize: 14, fontFamily: 'system-ui, sans-serif', fontWeight: 'normal', color: '#0ea5e9' },
      },
      radius: {
        // bounds of the value scale, taken from the options rather than the data
        min: 0,
        max: 12,
        nice: false,
        ringCount: 6,
        // the rings
        gridLine: { enabled: true, stroke: '#f43f5e', width: 1, lineDash: [], opacity: 0.25 },
        // the vertical the ring values are read along
        line: { enabled: true, stroke: '#f43f5e', width: 2, lineDash: [4, 2] },
        // the ring values
        label: {
          enabled: true,
          fontSize: 11,
          fontFamily: 'system-ui, sans-serif',
          fontWeight: 'bold',
          color: '#f43f5e',
          format: ',.1f',
        },
        title: { enabled: true, text: 'Score', fontSize: 14, fontFamily: 'system-ui, sans-serif', fontWeight: 'normal', color: '#f43f5e' },
      },
    },
  };
}
ts
export function getData() {
  return [
    { skill: 'Speed', score: 7.5 },
    { skill: 'Quality', score: 8.2 },
    { skill: 'Reliability', score: 6.4 },
    { skill: 'Support', score: 5.1 },
    { skill: 'Price', score: 8.8 },
    { skill: 'Docs', score: 4.6 },
  ];
}

Что стоит вычитать из этой пары: nice: false оставляет max ровно там, куда его поставили, поэтому внешнее кольцо у розы не доходит до обода, а у радара ложится на него — и там, где ложится, линию оставляет за собой обод, а кольцо уступает. format на одной оси и formatter на другой: там, где заданы оба, побеждает formatter.

Опции оси ​

БлокОпции
number/logmin, max, nice, base (log), bandSpan (число)
timemin, max (Date/timestamp), bandSpan (мс)
categorypaddingInner, paddingOuter
полярные angle / radiusсм. Полярные оси; у radius ещё min, max, nice, ringCount

Полный список опций ​

ОпцияТипПо умолчаниюОписание
type'number' | 'category' | 'time' | 'log' | 'ordinal-time' | 'grouped-category'по сериямтип оси
position'bottom' | 'left' | 'top' | 'right'по типусторона оси
title.enabledbooleantrue при textзаголовок оси
title.textstring—текст заголовка
title.fontSizePixels12шрифт заголовка
title.colorColorValueforegroundцвет заголовка
line.enabledbooleanтолько ось категорийлиния оси
line.strokeColorValueaxis темы (светло-серый)цвет линии
line.widthPixels1толщина линии
line.lineDashPixels[]сплошнаяпунктир линии оси ([] — принудительно сплошная)
tick.enabledbooleanfalseтики
tick.sizePixels6длина тика
tick.widthPixels1толщина тика
tick.strokeColorValueaxis темы (светло-серый)цвет тика (tick.color — алиас)
tick.lineDashPixels[]сплошнойпунктир тика
label.enabledbooleantrueподписи делений
label.fontSizePixels11шрифт подписей
label.fontFamilystringшрифт темыгарнитура
label.colorColorValuemuted темыцвет подписей
label.spacingPixels8внешние подписи: отступ от тика или оси
label.insideSpacingPixels4внутренние подписи: отступ от оси
label.insideAlign'element' | 'gap''element'внутренние подписи: прижать к элементу или центрировать
label.insideGapPixels4внутренние подписи: зазор до элемента
label.placement'outside' | 'inside''outside'подписи у оси или внутри области построения
label.formatstring—format-строка (',.2f', '.0%', '%d %b')
label.formatter({ value, index }) => string—программный формат
label.rotationDegrees | 'auto'0наклон подписей по часовой стрелке (-45, -90); только у оси
label.avoidCollisionsbooleantrueпропуск пересекающихся подписей
label.overflow'thin' | 'ellipsis''thin'тесные подписи: прореживать или оставить все и обрезать
label.maxWidthPixels—наибольшая ширина подписи; длиннее — обрезается
label.ellipsisstring'..'знак на месте обрезки
gridLine.enabledbooleanтолько ось значенийлинии сетки
gridLine.strokeColorValueaxis темы (светло-серый)цвет сетки
gridLine.widthPixels1толщина
gridLine.lineDashPixels[][4, 4]пунктир сетки
interval.valuesunknown[]автоявные значения тиков
interval.minSpacingPixels8мин. расстояние подписей
crossLines[].type'line' | 'range''line'линия или диапазон
crossLines[].valueзначение—координата линии
crossLines[].range[от, до]—диапазон заливки
crossLines[].strokeColorValuemuted темыцвет линии
crossLines[].strokeWidthPixels1толщина линии
crossLines[].lineDashPixels[]—пунктир
crossLines[].fillColorValuemuted темызаливка диапазона
crossLines[].fillOpacityFraction0.12прозрачность заливки
crossLines[].label.textstring—текст подписи
crossLines[].label.colorColorValuemuted темыцвет подписи
crossLines[].label.fontSizePixels11шрифт подписи
min (number, log)numberдомен данныхнижняя граница
max (number, log)numberдомен данныхверхняя граница
nice (number)booleantrueокругление домена до «красивых» границ
base (log)number10основание логарифма
paddingInner (category, ordinal-time, grouped-category)Fraction0.2 (ordinal-time 0.25)зазор между элементами, доля шага
gap (category, ordinal-time, grouped-category)Pixels—зазор между элементами в px; главнее paddingInner
paddingOuter (category, ordinal-time, grouped-category)Fraction0.1внешний band-отступ
bandSpan (time, number)numberшаг данныхширина столбца в единицах оси (мс на временной оси)
groupSpacing (grouped-category)Pixels8отступ между подписями элементов и ярусом групп, и между ярусами
groupLabel.enabled (grouped-category)booleantrueярусы с надписями групп
groupLabel.fontSize (grouped-category)Pixels11размер шрифта надписи группы
groupLabel.fontFamily (grouped-category)stringшрифт темыгарнитура
groupLabel.fontWeight (grouped-category)FontWeight'bold'начертание надписи группы
groupLabel.color (grouped-category)ColorValueforegroundцвет надписи группы
groupLabel.format (grouped-category)string—строка формата для значения уровня
groupLabel.formatter (grouped-category)({ value, level, start, end }) => string—программное форматирование надписи группы
groupLabel.maxWidth (grouped-category)Pixelsширина прогона группынаибольшая ширина надписи группы

Подписи горизонтальных осей автоматически прореживаются при тесноте (label.avoidCollisions: false отключает) — либо обрезаются, либо поворачиваются под углом, см. Подписи, которые не влезают и Подписи под углом.

Overlays ​

Состояния «нет данных» и «загрузка» включены по умолчанию: пустой data показывает overlays.noData.text, а loading: true — overlays.loading.text.