GrChartLine

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

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

Когда брать

  • как величина менялась — выручка по месяцам, нагрузка по часам, остаток по дням: линия показывает ход, а не отдельные замеры;
  • несколько рядов рядом — легенда переключает серии, тултип показывает все значения в одной абсциссе;
  • в ряду есть пропускиnull рвёт линию, а не подставляет ноль; gaps рисует через провал перемычку, когда важнее читаемость тренда;
  • точек тысячиdecimate: 'auto' (по умолчанию) сокращает рисунок до двух вершин на пиксель, а курсор, клавиатура и скрытая таблица продолжают знать полный ряд. Разбор — ../model.md, раздел «Прореживание — проекция, а не данные».

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

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

Сглаживание не изобретает значений

curve: 'smooth' — монотонная кубика: она не выбрасывает кривую за диапазон соседних значений. Обычный кубический сплайн на резком перепаде даёт «горб» ниже минимума или выше максимума пары — то есть рисует значение, которого в данных нет. step уместен там, где величина и правда менялась ступенями: тариф, версия, статус.

Перемычка gaps всегда прямая, даже при smooth. Кривая через пропуск имела бы форму, то есть показывала бы ход значения там, где его не измеряли.

Марки появляются сами

showPoints: 'auto' рисует марки, пока ряд короткий (до шестидесяти точек). На длинном ряду они сливаются в сплошную полосу и мешают читать линию, поэтому пропадают. 'always' на пятидесяти тысячах точек — осознанный выбор потребителя, и стоит он ровно столько, сколько стоит.

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

Тело графика умеет рисоваться двумя способами. Ниже порога это 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(#…) холст не понимает, а на двадцати площадях градиент всё равно читается как каша.

Курсор и клавиатура работают с одним состоянием

activeIndexv-model: им синхронизируют пару графиков, чтобы курсор в одном подсвечивал ту же абсциссу в другом. hiddenSeries — тоже v-model, но компонент его не применяет сам: легенда излучает намерение, состояние принадлежит потребителю.

interactive: false превращает график в картинку: role="img" с именем, без фокуса, тултипа и клавиатуры. Это режим для печати и для плитки, по которой кликают целиком.

Скрытая таблица — не запасной вариант

dataTable: 'hidden' (по умолчанию) держит полные данные таблицей в дереве доступности, но не на экране. Скринридер читает значения, а не «график». 'visible' показывает её же под холстом, 'off' убирает совсем — и это осознанный отказ от доступности данных, а не оптимизация.

Приближение по абсциссе

zoom включает окно: 'brush' — протяжка по холсту, 'wheel' — колесо, 'both' — оба. По умолчанию выключено.

<GrChartLine 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', то есть бюджет рисунка:

<GrChartLine :series="series" />                              <!-- auto: как нарисовано -->

<GrChartLine :series="series" :data-table-max-rows="200" />   <!-- свой потолок -->

<GrChartLine :series="series" :data-table-max-rows="Infinity" /> <!-- всегда полная -->

<GrChartLine :series="series" data-table="off" />             <!-- таблицы нет вовсе -->

Выше потолка таблица печатает те же точки, что нарисованы, и говорит об этом пометкой в подвале. Поточечная полнота при этом не теряется: стрелки обходят весь ряд и проговаривают каждую точку. Таблица отвечает за обзор, клавиатура — за точное значение; разбор — ../model.md.

'auto' — это «столько строк, сколько можно прочитать». Бюджет рисунка берётся, когда он есть; при decimate: 'never' его нет, и остаётся фиксированный потолок с равномерной выборкой.

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

Порог рисуется опорой, а не серией

План, норма и граница допустимого — это проп references, а не ряд из константы: серия-константа попала бы в легенду, растянула бы ось и уехала бы в таблицу как данные. Разбор — ../model.md, раздел «Опора — не серия».

Две оси — осознанное решение

Ряды разного порядка (деньги и штуки) читаются на одном графике только с двумя осями, а две оси позволяют подогнать любую пару под видимую корреляцию. Поэтому axis: 'right' у серии не работает, пока не включён dualAxis. Инварианты — ../model.md, раздел «Вторая ось значений».

Установка

npm i @feugene/granularity-charts

Импорт

import { GrChartLine } from '@feugene/granularity-charts/components/GrChartLine'

API

API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.

Примеры 8

Basic

Basic
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GR_TONES, GrButton, type GrTone } from '@feugene/granularity'

// `GrChartLine` подставляется авто-импортом (`unplugin-vue-components`).

// Ось времени выводится из данных: первый `x` — `Date`, значит шкала `time`.
const points = Array.from({ length: 14 }, (_, day) => ({
  x: new Date(2026, 6, day + 1),
  y: Math.round(120 + Math.sin(day / 2) * 40 + day * 6),
}))

/**
 * Линия красится **ролью темы**, а не готовым цветом: при переключении
 * 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 series = computed(() => [{
  id: 'revenue',
  label: 'Выручка',
  data: points,
  color: toneColor[lineTone.value],
}])

/**
 * Курсор поднят в `v-model`, поэтому его можно показать рядом — и так же
 * прокинуть во второй график, чтобы пара двигалась синхронно.
 */
const active = ref<number | null>(null)

const readout = computed(() => {
  const point = active.value === null ? null : points[active.value]

  if (!point)
    return null

  return {
    date: point.x.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }),
    value: point.y.toLocaleString('ru-RU'),
  }
})
</script>

<template>
  <div class="grid gap-3">
    <div class="flex items-baseline justify-between gap-4">
      <span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">Выручка, две недели</span>

      <!-- Место под показания зарезервировано всегда: иначе строка прыгает на каждом наведении. -->
      <span class="min-h-6 text-[length:var(--gr-control-text-sm)]">
        <template v-if="readout">
          <span class="text-[var(--gr-muted-fg)]">{{ readout.date }}</span>
          <strong class="ml-2 [font-variant-numeric:tabular-nums]">{{ readout.value }} ₽</strong>
        </template>
        <span v-else class="text-[var(--gr-muted-fg)]">Наведите курсор или нажмите стрелку</span>
      </span>
    </div>

    <GrChartLine
      v-model:active-index="active"
      :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>
</template>

Canvas

Canvas
<script setup lang="ts">
import { computed, ref } from 'vue'

/**
 * Один и тот же график двумя рендерерами.
 *
 * Демо существует ради сверки: переключатель меняет **только** порог, данные и
 * настройки остаются теми же. Если картинки различаются — это дефект, а не
 * особенность второго пути.
 */
const SERIES = 20
const POINTS = 240

const series = Array.from({ length: SERIES }, (_, s) => ({
  id: `host-${s + 1}`,
  label: `Узел ${s + 1}`,
  x: Array.from({ length: POINTS }, (_, i) => i),
  y: Array.from({ length: POINTS }, (_, i) => Number((
    50 + Math.sin((i + s * 17) / 30) * 18 + Math.sin(i / 6) * 3 + s * 0.4
  ).toFixed(2))),
}))

/** Вершин на рисунке: столько же в обоих режимах — их и сравнивает порог. */
const vertices = SERIES * POINTS

const renderer = ref<'svg' | 'canvas'>('canvas')

// Порог задаётся так, чтобы переключатель менял ровно ветку и ничего больше.
const threshold = computed(() => (renderer.value === 'canvas' ? 1000 : 0))

const rendererOptions = [
  { value: 'svg', label: 'SVG' },
  { value: 'canvas', label: 'Canvas' },
] satisfies Array<{ value: 'svg' | 'canvas', label: string }>
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-3">
      <GrSegmented v-model="renderer" :options="rendererOptions" size="sm" />
      <span class="showcase-demo-text text-sm opacity-70">
        {{ SERIES }} рядов по {{ POINTS }} точек — {{ vertices.toLocaleString('ru') }} вершин
      </span>
    </div>

    <GrChartLine
      :series="series"
      :canvas-threshold="threshold"
      :height="320"
      aria-label="Загрузка узлов"
    />

    <p class="showcase-demo-text text-sm opacity-70">
      Переключатель меняет <strong>только порог</strong> — данные, сглаживание и цвета те же.
      Картинки обязаны совпадать: второй рендерер заведён ради цены кадра, а не ради другого вида.
      Наведите курсор и пройдитесь стрелками в обоих режимах — тултип, клавиатура и скрытая таблица
      работают одинаково, потому что живут на оверлее и на полных рядах, а не на марках.
    </p>

    <p class="showcase-demo-text text-sm opacity-70">
      Порог считается в <strong>нарисованных вершинах</strong>, а не в точках: прореживание режет
      каждый ряд до предела экрана по отдельности, поэтому один длинный ряд стоит миллисекунды, а
      двадцать коротких — целого кадра. По замеру SVG растёт линейно, около 0,8 мс на ряд из 2400
      вершин, и на двадцати перестаёт помещаться в 16 мс; у холста та же работа занимает 1,7 мс.
      Умолчание — 24 000 вершин, половина бюджета. <code>canvasThreshold: 0</code> выключает холст
      совсем: рисунок остаётся векторным для печати и экспорта.
    </p>
  </div>
</template>

Decimate

Decimate
<script setup lang="ts">
import { computed, ref, useTemplateRef, watchEffect } from 'vue'

/**
 * Десять тысяч замеров и рисунок, который от них не зависит.
 *
 * Счётчик вершин читает ту самую строку `d`, которую браузер получает на
 * отрисовку, — иначе демонстрация была бы обещанием, а не измерением.
 */
const POINTS = 10_000

const series = [{
  id: 'cpu',
  label: 'Загрузка CPU',
  x: Array.from({ length: POINTS }, (_, index) => index),
  y: Array.from({ length: POINTS }, (_, index) => {
    const wave = Math.sin(index / 420) * 18 + Math.sin(index / 37) * 4
    // Одиночный всплеск: он и есть проверка — LTTB обязан его сохранить.
    const spike = index === 6137 ? 41 : 0
    return Number((46 + wave + spike).toFixed(2))
  }),
}]

const decimate = ref<'auto' | 'never'>('auto')

/**
 * Скрытая таблица данных — отдельным переключателем, потому что это отдельное
 * решение приложения, а не следствие режима прореживания.
 */
type TableMode = 'auto' | 'full' | 'off'

const tableMode = ref<TableMode>('auto')

const tableProps = computed(() => (
  tableMode.value === 'off'
    ? { dataTable: 'off' as const }
    : {
        dataTable: 'hidden' as const,
        dataTableMaxRows: tableMode.value === 'full' ? Number.POSITIVE_INFINITY : ('auto' as const),
      }
))

const tableHint: Record<TableMode, string> = {
  auto: 'Столько строк, сколько можно прочитать. При «Прореживать» это бюджет рисунка — таблица печатает ровно нарисованные точки; при «Все точки» бюджета нет, и остаётся фиксированный потолок с равномерной выборкой.',
  full: 'Весь ряд строками в дереве доступности, независимо от рисунка. Прочитать подряд десять тысяч строк невозможно.',
  off: 'Таблицы нет. Данные остаются достижимы поточечно: стрелки обходят полный ряд и проговаривают каждую точку.',
}

const tableRows = ref(0)

const chartEl = useTemplateRef<HTMLElement>('chartEl')
const vertices = ref(0)

watchEffect(() => {
  // Читаем после того, как режим уже применён к разметке.
  void decimate.value
  void tableMode.value
  requestAnimationFrame(() => {
    const d = chartEl.value?.querySelector('[data-gr-chart-series="cpu"]')?.getAttribute('d') ?? ''
    vertices.value = (d.match(/[ML]/g) ?? []).length
    tableRows.value = chartEl.value?.querySelectorAll('[data-gr-chart-table] tbody tr').length ?? 0
  })
})

const hint = computed(() => (
  decimate.value === 'auto'
    ? 'Форма ряда и всплеск на месте, а вершин в пути — сотни вместо десяти тысяч.'
    : 'Каждый замер попал в путь целиком. Рисунок тот же: экран всё равно не покажет больше двух вершин на пиксель.'
))
</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)]">
        {{ POINTS.toLocaleString('ru') }} замеров, вершин в пути: <strong>{{ vertices }}</strong>
      </span>

      <GrSegmented
        v-model="decimate"
        size="sm"
        :options="[
          { value: 'auto', label: 'Прореживать' },
          { value: 'never', label: 'Все точки' },
        ]"
        aria-label="Режим прореживания"
      />
    </div>

    <div ref="chartEl">
      <GrChartLine
        v-bind="tableProps"
        :series="series"
        :decimate="decimate"
        :height="260"
        :x-tick-format="(value: number) => `${Math.round(value / 60)} ч`"
        aria-label="Загрузка CPU за неделю"
      />
    </div>

    <div class="flex flex-wrap items-baseline justify-between gap-3">
      <span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
        Скрытая таблица для скринридера: строк <strong>{{ tableRows.toLocaleString('ru') }}</strong>
      </span>

      <GrSegmented
        v-model="tableMode"
        size="sm"
        :options="[
          { value: 'auto', label: 'Авто (по порогу)' },
          { value: 'full', label: 'Полная' },
          { value: 'off', label: 'Без таблицы' },
        ]"
        aria-label="Скрытая таблица данных"
      />
    </div>

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      {{ tableHint[tableMode] }}
    </p>

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      {{ hint }} Прореживание сокращает <strong>рисунок</strong>, а не данные:
      <kbd>End</kbd> ставит курсор на десятитысячную точку в обоих режимах.
      На шумном участке активная марка может отойти от линии — линия здесь
      сводка, а марка и тултип правда.
    </p>
  </div>
</template>

Dual Axis

Dual Axis
<script setup lang="ts">
import { ref } from 'vue'

/**
 * Деньги и штуки на одной оси не живут: ряд меньшего порядка схлопывается в
 * линию у нуля, и вопрос «как связаны выручка и движение» приходится
 * рассматривать по двум картинкам.
 *
 * Вторая ось включается осознанно: она же позволяет подогнать любые два ряда
 * под видимую корреляцию.
 */
const months = ['Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт']

const series = [
  { id: 'mrr', label: 'MRR, $', axis: 'right' as const, x: months, y: [38200, 39800, 41100, 40400, 43600, 46200] },
  { id: 'new', label: 'Новые', x: months, y: [186, 204, 178, 231, 268, 294] },
  { id: 'churn', label: 'Отток', x: months, y: [92, 88, 104, 96, 81, 74] },
]

const dualAxis = 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="dualAxis" size="sm">
          Вторая ось
        </GrSwitch>
    </div>

    <GrChartLine
      :series="series"
      :dual-axis="dualAxis"
      :height="280"
      show-legend
      aria-label="Выручка и движение подписок"
    />

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Выключите вторую ось — и движение подписок ляжет на ноль: сорок тысяч долларов задают масштаб,
      в котором двести штук неразличимы. Делений у осей поровну, чтобы сетка не двоилась, и рисуется
      она <strong>только по левой</strong>. В скрытой таблице колонка называет свою ось — иначе
      значения из разных шкал стояли бы рядом без пояснения.
    </p>
  </div>
</template>

References

References
<script setup lang="ts">
import { computed, ref } from 'vue'

/**
 * Порог, нарисованный серией из константы, врёт трижды: попадает в легенду
 * равноправным рядом, растягивает домен оси и уезжает в скрытую таблицу как
 * данные. Опора не делает ничего из этого.
 *
 * Пороги подобраны так, чтобы переключатель было **видно**: рабочий коридор
 * лежит внутри данных и виден всегда, а договорный потолок втрое выше любого
 * значения ряда — включив его в домен, ось растягивается, и сами данные
 * схлопываются в полосу у нуля. Ровно то, ради чего проп существует.
 */
const days = Array.from({ length: 30 }, (_, index) => new Date(2026, 6, index + 1))

const series = [
  {
    id: 'cost',
    label: 'Себестоимость кредита',
    data: days.map((x, index) => ({ x, y: 0.026 + Math.sin(index / 4) * 0.004 + index * 0.0004 })),
  },
]

const references = [
  // Внутри данных: виден в обоих положениях переключателя.
  { axis: 'y' as const, value: [0.03, 0.035] as const, label: 'Рабочий коридор' },
  // Втрое выше максимума ряда: он и есть предмет демонстрации.
  { axis: 'y' as const, value: 0.12, label: 'Потолок по договору', color: 'var(--gr-danger)' },
]

const includeInDomain = ref(false)

const hint = computed(() => (
  includeInDomain.value
    ? 'Ось растянулась до 0.12, чтобы вместить договорный потолок, — и весь ряд сжался в полосу у нижнего края. Различить на нём дневные колебания больше нельзя, зато видно, как далеко до потолка.'
    : 'Ось построена по данным: колебания себестоимости читаются, рабочий коридор виден. Договорный потолок при этом за краем холста — его линии нет, но в описании графика и в примечании таблицы он остался.'
))
</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="includeInDomain" size="sm">
        Вместить пороги в ось
      </GrSwitch>
    </div>

    <GrChartLine
      :series="series"
      :references="references"
      :include-references-in-domain="includeInDomain"
      :height="280"
      :value-format="{ precision: 3 }"
      aria-label="Себестоимость кредита с порогами"
    />

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      {{ hint }}
    </p>

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Переключатель — это проп <code>includeReferencesInDomain</code>, и по умолчанию он
      <strong>выключен</strong>. Причина видна на этом же графике: договорный потолок
      <code>0.12</code> втрое выше любого значения ряда, и вместить его в ось значит отдать порогу
      четыре пятых холста, а данным — оставшуюся пятую. Порог важен, но рассматривают всё-таки
      данные. Включать его в домен стоит там, где сам порог и есть предмет разговора: «сколько нам
      ещё до лимита».
    </p>

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Опора, ушедшая за край холста, <strong>не рисуется</strong> — прижать её к рамке значило бы
      показать порог там, где его нет. Но из описания графика и из примечания под скрытой таблицей
      она не пропадает: «порог не виден» и «порога нет» — разные утверждения, и читателю без зрения
      достаётся первое, а не второе.
    </p>
  </div>
</template>

Series

Series
<script setup lang="ts">
import { ref } from 'vue'

// Шесть серий на палитре из пяти ролей: шестая повторяет цвет первой, но
// отличается формой точки — цвет никогда не единственный различитель.
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']

const series = ['North', 'South', 'East', 'West', 'Online', 'Partners'].map((label, index) => ({
  id: label.toLowerCase(),
  label,
  data: months.map((month, position) => ({
    x: month,
    y: Math.round(40 + index * 12 + Math.cos(position + index) * 15),
  })),
}))

const hidden = ref<string[]>(['partners'])
</script>

<template>
  <div class="grid gap-4">
    <GrChartLine
      v-model:hidden-series="hidden"
      :series="series"
      :height="240"
      show-legend
      legend-position="bottom"
      show-points="always"
      show-grid="both"
    />

    <p class="showcase-demo-text text-sm">
      <span class="opacity-70">hiddenSeries=</span>
      <code>{{ hidden.length ? hidden.join(', ') : '—' }}</code>
    </p>
  </div>
</template>

States

States
<script setup lang="ts">
import { computed, ref } from 'vue'

import type { GrSegmentedOption } from '@feugene/granularity'

/**
 * Три состояния одного графика — как их видит пользователь, а не как они
 * называются в пропах.
 *
 * Сценарий: температура в серверной, замер раз в час. С 4:00 до 6:00 датчик был
 * отключён на обслуживание — значений за эти часы **не существует**, и это не
 * то же самое, что ноль.
 */
const readings: (number | null)[] = [
  21.4,
  21.6,
  21.9,
  22.4,
  null,
  null,
  23.1,
  23.6,
  24.2,
  24.8,
  25.1,
  24.6,
  24.1,
  23.7,
  23.9,
  24.4,
  24.9,
  25.4,
  25.2,
  24.7,
  23.8,
  22.9,
  22.2,
  21.8,
]

const neighbour: (number | null)[] = [
  20.8,
  21.0,
  21.1,
  21.5,
  21.9,
  22.0,
  22.3,
  22.8,
  23.1,
  23.5,
  23.8,
  23.4,
  23.0,
  22.7,
  22.8,
  23.1,
  23.5,
  23.9,
  23.7,
  23.2,
  22.6,
  22.0,
  21.5,
  21.1,
]

const series = [
  {
    id: 'rack-a',
    label: 'Стойка A',
    data: readings.map((value, hour) => ({ x: new Date(2026, 6, 12, hour), y: value })),
  },
  {
    id: 'rack-b',
    label: 'Стойка B',
    data: neighbour.map((value, hour) => ({ x: new Date(2026, 6, 12, hour), y: value })),
  },
]

/**
 * Пустой период — это **объявленные серии без точек**, а не отсутствие серий.
 * Так отвечает бэкенд: набор рядов известен заранее, строк за период нет. Ровно
 * в этом случае легенда и объясняла цвета, которых на экране нет.
 */
const emptySeries = series.map(item => ({ ...item, data: [] }))

type State = 'data' | 'loading' | 'empty'

const state = ref<State>('data')
const showTable = ref(false)

/**
 * Чем закрыть два часа без замеров.
 *
 * `hidden` честнее всего: данных нет — линии нет. Но разорванная линия читается
 * как поломка графика, поэтому есть и перемычка — заметно отличная от линии,
 * чтобы не выдавать себя за измеренное.
 */
const gaps = ref<'hidden' | 'shadow' | 'dashed'>('shadow')

const gapOptions: GrSegmentedOption[] = [
  { value: 'hidden', label: 'Разрыв' },
  { value: 'shadow', label: 'Тень' },
  { value: 'dashed', label: 'Штрих' },
]

const stateOptions: GrSegmentedOption[] = [
  { value: 'data', label: 'Данные' },
  { value: 'loading', label: 'Загрузка' },
  { value: 'empty', label: 'Нет данных' },
]

const gapHours = computed(() => readings.filter(value => value === null).length)

function formatTemperature(value: number): string {
  return `${value.toFixed(0)} °C`
}
</script>

<template>
  <div class="grid gap-4">
    <!-- Панель управления графиком — то, что в продукте стоит над ним всегда. -->
    <div class="flex flex-wrap items-center justify-between gap-3">
      <GrSegmented v-model="state" :options="stateOptions" size="sm" aria-label="Состояние графика" />
      <div class="flex flex-wrap items-center gap-3">
        <GrSegmented v-model="gaps" :options="gapOptions" size="sm" aria-label="Как показать пропуск" />
        <GrSwitch v-model="showTable" size="sm">Таблица данных</GrSwitch>
      </div>
    </div>

    <GrChartLine
      :series="state === 'empty' ? emptySeries : series"
      :loading="state === 'loading'"
      :height="220"
      :gaps="gaps"
      :y-tick-format="formatTemperature"
      :data-table="showTable ? 'visible' : 'hidden'"
      empty-text="За выбранные сутки замеров нет"
      aria-label="Температура в серверной, стойка A, за сутки"
    />

    <p class="showcase-demo-text text-sm text-[var(--gr-muted-fg)]">
      В ряду {{ gapHours }} часа без значений — датчик был отключён на обслуживание. К нулю ряд там не сводится и
      сплошной линией не соединяется: и то и другое нарисовало бы температуру, которой не измеряли. В таблице данных
      на этом месте стоит «нет значения» — в любом режиме.
      <br>
      Переключатель решает, чем закрыть провал <em>визуально</em>. «Разрыв» честнее всего, но читается как поломка
      графика; «тень» и «штрих» показывают, куда ряд ушёл за это время, оставаясь заметно непохожими на настоящую
      линию. Перемычка всегда прямая, даже когда линия сглажена: кривая придумала бы ход значения.
      <br>
      Скелет загрузки помечает корень <code>aria-busy</code>, а пустое состояние — это <code>GrEmptyState</code> ядра
      со своим текстом: «нет данных» и «данные ещё едут» звучат для пользователя по-разному.
    </p>
  </div>
</template>

Zoom

Zoom
<script setup lang="ts">
import { computed, ref, useTemplateRef, watchEffect } from 'vue'

/**
 * Приближение к участку длинного ряда.
 *
 * Ряд тот же, что у демонстрации прореживания, и это существенно: на полном
 * ряде мелкая рябь ложится сплошной штриховкой — бюджет даёт одну вершину на
 * семь точек. В суженном окне те же данные рисуются целиком, и рябь становится
 * различимой формой.
 */
const POINTS = 10_000

const series = [{
  id: 'cpu',
  label: 'Загрузка CPU',
  x: Array.from({ length: POINTS }, (_, index) => index),
  y: Array.from({ length: POINTS }, (_, index) => {
    const wave = Math.sin(index / 420) * 18 + Math.sin(index / 37) * 4
    const ripple = Math.sin(index / 3) * 1.6
    return Number((46 + wave + ripple).toFixed(2))
  }),
}]

const xWindow = ref<readonly [number, number] | null>(null)

const hours = (value: number) => `${Math.round(value / 60)} ч`

const bounds = computed(() => (
  xWindow.value === null
    ? 'весь ряд'
    : `${hours(xWindow.value[0])}${hours(xWindow.value[1])}`
))

/**
 * Скрытая таблица данных — переключателем, потому что решает это приложение.
 *
 * Строка на точку читаема, пока строк немного; на десяти тысячах такую таблицу
 * не читает подряд никто, а перестроение её стоит сотню миллисекунд.
 */
type TableMode = 'auto' | 'full' | 'off'

const tableMode = ref<TableMode>('auto')

const tableProps = computed(() => (
  tableMode.value === 'off'
    ? { dataTable: 'off' as const }
    : {
        dataTable: 'hidden' as const,
        dataTableMaxRows: tableMode.value === 'full' ? Number.POSITIVE_INFINITY : ('auto' as const),
      }
))

const tableHint: Record<TableMode, string> = {
  auto: 'Таблица печатает те же точки, что нарисованы, и говорит об этом пометкой в подвале. Стрелками по-прежнему доступны все.',
  full: 'Весь ряд строками в дереве доступности. Читать подряд его невозможно, а каждая смена окна перестраивает всё заново.',
  off: 'Таблицы нет вовсе. Данные остаются достижимы поточечно: стрелки обходят ряд и проговаривают каждую точку.',
}

const chartEl = useTemplateRef<HTMLElement>('chartEl')
const tableRows = ref(0)

watchEffect(() => {
  void tableMode.value
  void xWindow.value
  requestAnimationFrame(() => {
    tableRows.value = chartEl.value?.querySelectorAll('[data-gr-chart-table] tbody tr').length ?? 0
  })
})

const points = computed(() => (
  xWindow.value === null
    ? POINTS
    : Math.round(xWindow.value[1]) - Math.round(xWindow.value[0]) + 1
))
</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)]">
        Показано: <strong>{{ bounds }}</strong>, точек: <strong>{{ points.toLocaleString('ru') }}</strong>
      </span>

      <GrButton size="sm" variant="outline" :disabled="xWindow === null" @click="xWindow = null">
        Весь ряд
      </GrButton>
    </div>

    <div ref="chartEl">
      <GrChartLine
        v-model:x-window="xWindow"
        v-bind="tableProps"
        :series="series"
        zoom="both"
        :height="260"
        :x-tick-format="hours"
        aria-label="Загрузка CPU за неделю"
      />
    </div>

    <div class="flex flex-wrap items-baseline justify-between gap-3">
      <span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
        Скрытая таблица для скринридера: строк <strong>{{ tableRows.toLocaleString('ru') }}</strong>
      </span>

      <GrSegmented
        v-model="tableMode"
        size="sm"
        :options="[
          { value: 'auto', label: 'Авто (по порогу)' },
          { value: 'full', label: 'Полная' },
          { value: 'off', label: 'Без таблицы' },
        ]"
        aria-label="Скрытая таблица данных"
      />
    </div>

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      {{ tableHint[tableMode] }}
    </p>

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Протяните по холсту или покрутите колесо — окно сузится, и мелкая рябь из
      сплошной штриховки станет различимой формой: бюджет прореживания считается
      от ширины области, а точек в окне меньше, и на каждую приходится больше
      вершин. Окно выбирает <strong>данные</strong>,
      а не обрезает рисунок: <kbd>End</kbd> ведёт к последней видимой точке, а
      скрытая таблица печатает строки окна. С клавиатуры то же самое: <kbd>+</kbd>
      и <kbd></kbd> приближают к активной точке, <kbd>Shift</kbd> со стрелками
      сдвигает окно, <kbd>0</kbd> возвращает весь ряд.
    </p>
  </div>
</template>

Доступность

Паттерн APG
При нескольких сериях ↑/↓ переключают читаемую серию
Клавиши
точка ряда по оси X

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

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