GrChartLine
Берут, когда как величина менялась.
Когда брать
- как величина менялась — выручка по месяцам, нагрузка по часам, остаток по дням: линия показывает ход, а не отдельные замеры;
- несколько рядов рядом — легенда переключает серии, тултип показывает все значения в одной абсциссе;
- в ряду есть пропуски —
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 | Холст |
|---|---|---|
| 1 | 1,1 мс | 0,4 мс |
| 8 | 6,7 мс | 0,6 мс |
| 20 | 16,3 мс | 1,7 мс |
canvasThreshold: 0 выключает холст совсем — на случай, когда рисунок обязан
остаться векторным: печать, экспорт SVG, свой CSS поверх марок.
Доступность от смены рендерера не меняется вовсе. Курсор, клавиатура,
тултип и скрытая таблица работают с оверлеем и с полными рядами, а не с марками:
холст для них не существует, он aria-hidden и не ловит указатель. Это и было
условием, при котором второй рендерер вообще допустили.
Что холст рисует иначе. Сетка переезжает в него же: холст лежит под <svg>,
чтобы оси и активная точка остались сверху, — а сетка обязана остаться под
рядами. У площади градиентная заливка становится сплошной: url(#…) холст не
понимает, а на двадцати площадях градиент всё равно читается как каша.
Курсор и клавиатура работают с одним состоянием
activeIndex — v-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
<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
<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
<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
<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
<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
<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
<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
<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