GrChartLine
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- how a quantity changed — revenue by month, load by hour, the balance by day: a line shows the course rather than individual measurements;
- several series side by side — the legend toggles the series, and the tooltip shows all of the values at one abscissa;
- there are gaps in the series —
nullbreaks the line rather than substituting a zero;gapsdraws a bridge across a gap when the readability of the trend matters more; - there are thousands of points —
decimate: 'auto'(the default) reduces the drawing to two vertices per pixel, while the cursor, the keyboard and the hidden table keep knowing the full series. The details —../model.md, the section “Decimation is a projection, not the data”.
When to take something else
| Need | Take |
|---|---|
| Show the whole and the contribution of its parts | GrChartArea |
| Compare quantities across categories | GrChartBar |
| Show what one whole consists of | GrChartPie |
| Compare the shape of a profile across several axes | GrChartRadar |
| A trend in a table cell, without axes and a frame | GrSparkline |
| One number large, with a trend | GrStatistic |
Smoothing does not invent values
curve: 'smooth' is a monotone cubic: it does not throw the curve beyond the range of the
neighbouring values. An ordinary cubic spline on a sharp drop gives a “hump” below the minimum or
above the maximum of a pair — that is, it draws a value that is not in the data. step is
appropriate where a quantity really did change in steps: a tariff, a version, a status.
The gaps bridge is always straight, even with smooth. A curve across a gap would have a shape,
that is, it would show the course of a value where it was not measured.
The marks appear by themselves
showPoints: 'auto' draws the marks while the series is short (up to sixty points). On a long series
they merge into a solid band and get in the way of reading the line, so they disappear. 'always' on
fifty thousand points is a deliberate choice of the consumer, and it costs exactly what it costs.
Above the threshold it is drawn by a canvas
The body of the chart can be drawn in two ways. Below the threshold it is SVG, above it a
<canvas>; the component chooses itself, and there is no “which renderer” prop.
The threshold is counted in drawn vertices rather than in points — canvasThreshold, 24,000 by
default. The difference is substantial: decimation cuts every series down to the limit of the
screen (about two vertices per pixel), so one series of a hundred thousand points is drawn as 2,400
vertices and costs milliseconds, while twenty series of 2,400 — the same 48,000 points — cost
sixteen, that is, a whole frame.
The measured numbers at a width of 1200px, 2,400 vertices per series:
| Series | SVG | Canvas |
|---|---|---|
| 1 | 1.1 ms | 0.4 ms |
| 8 | 6.7 ms | 0.6 ms |
| 20 | 16.3 ms | 1.7 ms |
canvasThreshold: 0 switches the canvas off entirely — for the case where the drawing has to stay
vector: printing, an SVG export, CSS of your own over the marks.
Accessibility does not change at all with the change of renderer. The cursor, the keyboard, the
tooltip and the hidden table work with the overlay and with the full series rather than with the
marks: the canvas does not exist for them, it is aria-hidden and does not catch the pointer. That
was the condition on which a second renderer was allowed at all.
What the canvas draws differently. The grid moves into it as well: the canvas lies under the
<svg> so that the axes and the active point stay on top — and the grid has to stay under the
series. In an area chart the gradient fill becomes solid: the canvas does not understand url(#…),
and across twenty areas a gradient reads as a mess anyway.
The cursor and the keyboard work with one state
activeIndex is a v-model: a pair of charts is synchronised with it, so that the cursor in one
highlights the same abscissa in the other. hiddenSeries is a v-model too, but the component does
not apply it itself: the legend emits the intent, and the state belongs to the consumer.
interactive: false turns the chart into a picture: a role="img" with a name, with no focus, no
tooltip and no keyboard. That is the mode for printing and for a tile that is clicked as a whole.
Zooming along the abscissa
zoom switches the window on: 'brush' is a drag across the canvas, 'wheel' is the wheel, and
'both' is both. It is off by default.
<GrChartLine v-model:x-window="window" :series="series" zoom="both" />
<GrButton :disabled="window === null" @click="window = null">
The whole series
</GrButton>
The window selects the data rather than cropping the drawing: the positions, the cursor, the keyboard, the hidden table and the span of the value axis are computed from it. The practical consequence is that zooming reveals the fine structure that on the full series lies as a solid hatching: the decimation budget is computed from the width of the area, and there are fewer points in the window, so more vertices fall to each of them.
The keyboard works whenever zoom is on: +/- zoom towards the active point, Shift+arrows
shift the window, and 0 returns the whole series. The union of the prop enumerates only pointer
gestures — zooming has no switchable keyboard by design (../a11y.md, the section
on zooming).
v-model:x-window is not mandatory — without a binding the chart zooms by itself. It is bound for
something else: a synchronised pair of charts, a reset button next to the canvas, keeping the zoom in
the address bar.
The bounds are accepted in the same form as the abscissas of the points (Date, an ISO string, a
number) and go out as numbers. With a window set, activeIndex addresses it rather than the
whole series.
A threshold is drawn as a reference, not as a series
A plan, a norm and the limit of the acceptable are the references prop rather than a series made of a constant: a
constant series would enter the legend, would stretch the axis and would travel into the table as data. The details —
../model.md, the section “A reference is not a series”.
Two axes are a deliberate decision
Series of different orders (money and units) are readable on one chart only with two axes, and two
axes let any pair be fitted to a visible correlation. That is why the axis: 'right' of a series
does not work until dualAxis is switched on. The invariants —
../model.md, the section “The second value axis”.
Install
npm i @feugene/granularity-chartsImport
import { GrChartLine } from '@feugene/granularity-charts/components/GrChartLine'API
The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.
Examples 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>Accessibility
- APG pattern
При нескольких сериях ↑/↓ переключают читаемую серию