GrChartArea

Пакет: @feugene/granularity-chartsспутникГруппа: Прочее

Берут, когда целое и вклад частей в него во времени.

Когда брать

  • целое и вклад частей в него во времени — выручка по каналам, трафик по источникам, время по стадиям: stacked кладёт каждую серию на сумму предыдущих;
  • один ряд, у которого важен объём — заливка до нуля читается как «сколько всего», а не «на каком уровне»;
  • доли, меняющиеся во времениstacked: '100%' нормирует каждую позицию к единице, и видно перераспределение, а не рост;
  • ряды, которые обязаны просвечивать — при наложении без стека fill: 'auto' даёт градиент, и нижний ряд остаётся виден.

Когда взять другое

НужноБерите
Показать ход значения, объём не важенGrChartLine
Сравнить величины по категориямGrChartBar
Показать состав целого на один моментGrChartPie
Сравнить форму профиля по нескольким осямGrChartRadar
Тренд в ячейке таблицыGrSparkline

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

Выше порога рисует холст

Тело графика умеет рисоваться двумя способами. Ниже порога это SVG, выше — <canvas>; выбирает сам компонент, пропа «каким рендерером» нет.

Порог считается в нарисованных вершинах, а не в точкахcanvasThreshold, по умолчанию 24 000. Разница существенная: прореживание режет каждый ряд до предела экрана (около двух вершин на пиксель), поэтому один ряд в сто тысяч точек рисуется как 2400 вершин и стоит миллисекунды, а двадцать рядов по 2400 — те же 48 000 точек — стоят шестнадцати, то есть целого кадра.

Числа замера при ширине 1200px, 2400 вершин на ряд:

РядовSVGХолст
11,1 мс0,4 мс
86,7 мс0,6 мс
2016,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

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

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

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

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

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты