GrChartArea
Берут, когда целое и вклад частей в него во времени.
Когда брать
- целое и вклад частей в него во времени — выручка по каналам, трафик по
источникам, время по стадиям:
stackedкладёт каждую серию на сумму предыдущих; - один ряд, у которого важен объём — заливка до нуля читается как «сколько всего», а не «на каком уровне»;
- доли, меняющиеся во времени —
stacked: '100%'нормирует каждую позицию к единице, и видно перераспределение, а не рост; - ряды, которые обязаны просвечивать — при наложении без стека
fill: 'auto'даёт градиент, и нижний ряд остаётся виден.
Когда взять другое
| Нужно | Берите |
|---|---|
| Показать ход значения, объём не важен | GrChartLine |
| Сравнить величины по категориям | GrChartBar |
| Показать состав целого на один момент | GrChartPie |
| Сравнить форму профиля по нескольким осям | GrChartRadar |
| Тренд в ячейке таблицы | GrSparkline |
Категорий больше семи-восьми — стек перестаёт читаться: полосы становятся тоньше
подписи, и сравнить их глазом уже нельзя. Это признак того, что нужен
GrChartBar с группировкой либо меньше серий.
Выше порога рисует холст
Тело графика умеет рисоваться двумя способами. Ниже порога это SVG, выше —
<canvas>; выбирает сам компонент, пропа «каким рендерером» нет.
Порог считается в нарисованных вершинах, а не в точках — canvasThreshold,
по умолчанию 24 000. Разница существенная: прореживание режет каждый ряд
до предела экрана (около двух вершин на пиксель), поэтому один ряд в сто тысяч
точек рисуется как 2400 вершин и стоит миллисекунды, а двадцать рядов по 2400 —
те же 48 000 точек — стоят шестнадцати, то есть целого кадра.
Числа замера при ширине 1200px, 2400 вершин на ряд:
| Рядов | SVG | Холст |
|---|---|---|
| 1 | 1,1 мс | 0,4 мс |
| 8 | 6,7 мс | 0,6 мс |
| 20 | 16,3 мс | 1,7 мс |
canvasThreshold: 0 выключает холст совсем — на случай, когда рисунок обязан
остаться векторным: печать, экспорт SVG, свой CSS поверх марок.
Доступность от смены рендерера не меняется вовсе. Курсор, клавиатура,
тултип и скрытая таблица работают с оверлеем и с полными рядами, а не с марками:
холст для них не существует, он aria-hidden и не ловит указатель. Это и было
условием, при котором второй рендерер вообще допустили.
Что холст рисует иначе. Сетка переезжает в него же: холст лежит под <svg>,
чтобы оси и активная точка остались сверху, — а сетка обязана остаться под
рядами. У площади градиентная заливка становится сплошной: url(#…) холст не
понимает, а на двадцати площадях градиент всё равно читается как каша.
Заливка выбирается по режиму, а не по вкусу
fill: 'auto' — градиент у наложения и плотная заливка у стека. Причина в
разном: наложенным рядам нужно просвечивать друг сквозь друга, а полосы стека
стоят встык, и градиент размыл бы границу между двумя соседними — то есть само
место, где кончается одна часть и начинается другая.
Значение точки остаётся своим
В стеке на точку приезжают границы полосы отдельными полями, но собственное y
не трогается. Тултип, скрытая таблица и живой регион говорят «партнёры — сто
девяносто», а не «шестьсот пятьдесят, потому что снизу лежит розница».
Скрытая серия из стека выпадает, лежащие выше опускаются. Ось значений считается по вершинам полос и всегда включает ноль: стек от ненулевой базы врёт про пропорции.
Стек строят по полным рядам. Пропуск — единственное место, где он честен не полностью: сумма в этой позиции занижена. Протянуть последнее известное значение было бы хуже — это нарисовать данные, которых не было.
Сто процентов отвечают на другой вопрос
stacked: true показывает величины и их сумму, stacked: '100%' — только
распределение: сумма в каждой позиции равна единице, и лента говорит, как
менялись доли, а не сколько всего было. Выбирать надо один вопрос: читатель не
знает, сравнивать ему высоты лент или их доли.
Нормируется рисунок, а не данные. Ось переходит в проценты, а тултип,
скрытая таблица и живой регион по-прежнему называют абсолютные величины.
Позиция с нулевой суммой нормировать не на что — там ноль, а не NaN.
Длинный ряд сокращается в рисунке, а не в данных
decimate: 'auto' (по умолчанию) прореживает вершины пути, когда точек больше, чем экран способен показать.
В стопке набор абсцисс общий на все серии — иначе низ верхней полосы интерполировался бы по одним X, а верх нижней
по другим, и заливка разошлась бы швами. Курсор, клавиатура, тултип и скрытая таблица знают полный ряд; разбор —
../model.md, раздел «Прореживание — проекция, а не данные».
Приближение по абсциссе
zoom включает окно: 'brush' — протяжка по холсту, 'wheel' — колесо,
'both' — оба. По умолчанию выключено.
<GrChartArea v-model:x-window="window" :series="series" zoom="both" />
<GrButton :disabled="window === null" @click="window = null">
Весь ряд
</GrButton>
Окно выбирает данные, а не обрезает рисунок: по нему считаются позиции, курсор, клавиатура, скрытая таблица и размах оси значений. Практическое следствие — приближение проявляет мелкую структуру, которая на полном ряде ложится сплошной штриховкой: бюджет прореживания считается от ширины области, а точек в окне меньше, и на каждую приходится больше вершин.
Клавиатура работает всегда, когда включён zoom: +/- приближают к активной
точке, Shift+стрелки сдвигают окно, 0 возвращает весь ряд. Union пропа
перечисляет только жесты указателя — отключаемой клавиатуры у приближения
нет by design (../a11y.md, раздел про приближение).
v-model:x-window не обязателен — без привязки график приближается сам.
Привязывают его ради другого: синхронной пары графиков, кнопки сброса рядом с
холстом, сохранения приближения в адресной строке.
Границы принимаются в том же виде, что абсциссы точек (Date, ISO-строка,
число), наружу уходят числами. activeIndex при заданном окне адресует его,
а не весь ряд.
Скрытая таблица и её потолок
Полные данные графика строками — то, что читает диктор вместо картинки. По умолчанию dataTable: 'hidden': в дереве
доступности, глазами не видна.
Строка на точку читаема, пока строк немного. Поэтому у таблицы есть потолок — dataTableMaxRows, по умолчанию
'auto', то есть бюджет рисунка:
<GrChartArea :series="series" /> <!-- auto: как нарисовано -->
<GrChartArea :series="series" :data-table-max-rows="200" /> <!-- свой потолок -->
<GrChartArea :series="series" :data-table-max-rows="Infinity" /> <!-- всегда полная -->
<GrChartArea :series="series" data-table="off" /> <!-- таблицы нет вовсе -->
Выше потолка таблица печатает те же точки, что нарисованы, и говорит об этом пометкой в подвале. Поточечная
полнота при этом не теряется: стрелки обходят весь ряд и проговаривают каждую точку. Таблица отвечает за обзор,
клавиатура — за точное значение; разбор — ../model.md.
'auto' — это «столько строк, сколько можно прочитать». Бюджет рисунка берётся, когда он есть; при
decimate: 'never' его нет, и остаётся фиксированный потолок с равномерной выборкой.
Что из этого нужно, решает приложение: держать десять тысяч строк в дереве доступности — его право, но и цена перестроения такой таблицы на нём.
Порог рисуется опорой, а не серией
План, норма и граница допустимого — это проп references, а не ряд из константы: серия-константа попала бы в
легенду, растянула бы ось и уехала бы в таблицу как данные. Разбор — ../model.md, раздел
«Опора — не серия».
Две оси — осознанное решение
Ряды разного порядка (деньги и штуки) читаются на одном графике только с двумя
осями, а две оси позволяют подогнать любую пару под видимую корреляцию. Поэтому
axis: 'right' у серии не работает, пока не включён dualAxis. Инварианты —
../model.md, раздел «Вторая ось значений».
Установка
npm i @feugene/granularity-chartsИмпорт
import { GrChartArea } from '@feugene/granularity-charts/components/GrChartArea'API
API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.
Примеры 4
Basic
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GR_TONES, GrButton, type GrTone } from '@feugene/granularity'
// `GrChartArea` подставляется авто-импортом (`unplugin-vue-components`).
/**
* Площадь вместо линии берут тогда, когда важен не только уровень, но и объём:
* «сколько всего набежало». Заливка гаснет к базовой линии — сплошная плашка
* утяжелила бы низ графика, где смотреть не на что.
*/
const traffic = Array.from({ length: 14 }, (_, day) => ({
x: new Date(2026, 6, day + 1),
y: Math.round(1800 + Math.sin(day / 2.2) * 420 + day * 55),
}))
/**
* Линия и заливка красятся **ролями темы**, а не готовыми цветами: при
* переключении light/dark ничего не пересоздаётся — значение роли меняет себя
* само. Отсюда `var(--gr-…)`, а не hex.
*/
const toneColor: Record<GrTone, string> = {
primary: 'var(--gr-primary)',
neutral: 'var(--gr-secondary)',
success: 'var(--gr-success)',
warning: 'var(--gr-warning)',
danger: 'var(--gr-danger)',
info: 'var(--gr-info)',
slate: 'var(--gr-slate)',
azure: 'var(--gr-azure)',
}
const lineTone = ref<GrTone>('primary')
const fillTone = ref<GrTone>('primary')
const series = computed(() => [{
id: 'sessions',
label: 'Сессии',
data: traffic,
color: toneColor[lineTone.value],
// Заливка — своя роль: линия обязана читаться на фоне, а площадь под ней —
// не спорить с сеткой. Совпадение цветов частый случай, но не закон.
fillColor: toneColor[fillTone.value],
}])
</script>
<template>
<div class="grid gap-3">
<span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Сессии, две недели
</span>
<GrChartArea
:series="series"
:height="220"
curve="smooth"
include-zero
aria-label="Сессии за две недели"
/>
<div class="flex flex-wrap items-center gap-2">
<span class="w-16 shrink-0 text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Линия
</span>
<GrButton
v-for="tone in GR_TONES"
:key="tone"
size="sm"
:variant="lineTone === tone ? 'primary' : 'outline'"
:tone="tone"
@click="lineTone = tone"
>
{{ tone }}
</GrButton>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="w-16 shrink-0 text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Заливка
</span>
<GrButton
v-for="tone in GR_TONES"
:key="tone"
size="sm"
:variant="fillTone === tone ? 'primary' : 'outline'"
:tone="tone"
@click="fillTone = tone"
>
{{ tone }}
</GrButton>
</div>
</div>
</template>Share
<script setup lang="ts">
import { ref } from 'vue'
/**
* Доля во времени — типичная задача именно для площадей: лента показывает, как
* менялось распределение, когда абсолютные числа растут у всех сразу.
*/
const months = ['Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт']
const series = [
{ id: 'free', label: 'Free', x: months, y: [820, 910, 1040, 1180, 1240, 1310] },
{ id: 'pro', label: 'Pro', x: months, y: [210, 246, 268, 331, 402, 486] },
{ id: 'team', label: 'Team', x: months, y: [42, 51, 58, 74, 96, 128] },
]
const normalized = ref(true)
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap items-baseline justify-between gap-4">
<span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Активные подписки по планам
</span>
<GrSwitch v-model="normalized" size="sm">
Сто процентов
</GrSwitch>
</div>
<GrChartArea
:series="series"
:stacked="normalized ? '100%' : true"
:height="280"
show-legend
data-table="visible"
aria-label="Активные подписки по планам"
/>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Обычный стек показывает величины и их сумму, <code>stacked: '100%'</code> — только
распределение. Нормируется <strong>рисунок</strong>, а не данные: в таблице под графиком
по-прежнему стоят абсолютные числа подписок, а не доли.
</p>
</div>
</template>Stacked
<script setup lang="ts">
import { computed, ref } from 'vue'
/**
* Стек и наложение отвечают на разные вопросы, и переключатель ниже — самый
* быстрый способ это увидеть.
*
* Стек показывает **целое и вклад каждого канала** в него: верхний край полос
* это выручка компании. Наложение показывает **каналы сами по себе**: сравнить
* два ряда между собой на стеке нельзя — второй ряд едет по горбам первого.
*/
const weeks = ['W27', 'W28', 'W29', 'W30', 'W31', 'W32', 'W33', 'W34']
const series = [
{ id: 'retail', label: 'Розница', x: weeks, y: [420, 460, 445, 510, 495, 540, 560, 585] },
{ id: 'partners', label: 'Партнёры', x: weeks, y: [180, 190, 230, 210, 245, 260, 250, 290] },
{ id: 'api', label: 'API', x: weeks, y: [60, 75, 90, 120, 140, 165, 190, 230] },
]
const mode = ref<'stacked' | 'overlay'>('stacked')
const hint = computed(() => (mode.value === 'stacked'
? 'Верхний край полос — выручка целиком. Высота полосы — вклад канала.'
: 'Ряды сравниваются между собой: заливка просвечивает там, где они пересекаются.'))
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap items-baseline justify-between gap-4">
<span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Выручка по каналам, тыс. ₽
</span>
<GrSegmented
v-model="mode"
size="sm"
:options="[{ value: 'stacked', label: 'Стек' }, { value: 'overlay', label: 'Наложение' }]"
aria-label="Режим площадей"
/>
</div>
<GrChartArea
:series="series"
:stacked="mode === 'stacked'"
:height="240"
show-legend
aria-label="Выручка по каналам за восемь недель"
/>
<p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
{{ hint }} Тултип и скрытая таблица в обоих режимах показывают
<strong>своё значение канала</strong>, а не сумму под ним.
</p>
</div>
</template>Zero
<script setup lang="ts">
/**
* Базовая линия площади — ноль, а не низ холста.
*
* Заливай мы всегда до нижнего края, убыток в минус десять нарисовался бы той
* же высотой, что и прибыль в плюс десять, — только чуть ниже. Здесь минус
* уходит под ось и читается как минус.
*/
const months = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен']
const series = [{
id: 'profit',
label: 'Прибыль',
x: months,
y: [-140, -95, -30, 25, 60, 40, 110, 165, 210],
}]
</script>
<template>
<div class="grid gap-3">
<span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
Операционная прибыль, тыс. ₽
</span>
<GrChartArea
:series="series"
:height="220"
curve="smooth"
aria-label="Операционная прибыль по месяцам"
/>
</div>
</template>Доступность
- Паттерн APG
При нескольких сериях ↑/↓ переключают читаемую серию- Клавиши
- точка ряда по оси X