GrSparkline
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- a trend in a table cell — a “trend” column next to a number: the direction is visible, and the exact values are read from the neighbouring column;
- a trend in a metric card — under the large number of
GrStatistic, so that “1,240” gets a history; - many charts on one page — the component measures nothing and keeps no listeners, so a hundred of them on the screen cost nothing;
- only the silhouette is needed —
summaryadds the minimum, the maximum and the last value in words for a screen reader without taking up room on the screen.
When to take something else
| Need | Take |
|---|---|
| Read values off the axes rather than the silhouette | GrChartLine |
| Show volume rather than the course | GrChartArea |
| Compare categories with one another | GrChartBar |
| Show a share of a whole | GrProgressBar |
There are deliberately no axes
Without axes a line has no scale, and two sparklines cannot be compared with one another: each is normalised over its own series, so a rise that looks identical in two rows of a table may mean plus two per cent and plus two hundred. A sparkline answers “did it grow or fall” — “by how much” is answered by the neighbouring column with the number.
A long series is shortened by itself
The canvas of a sparkline is fixed (a viewBox a hundred units wide), so the budget of vertices is known in advance
and there is nothing to measure: a series longer than the budget is decimated by LTTB with no prop at all. A hundred
sparklines in a column of a table stop adding up a hundred thousand vertices for the sake of a drawing a couple of
centimetres wide, while the shape and single outliers stay in place —
../model.md, the section “Decimation is a projection, not the data”.
Neither a cursor nor a keyboard
The component has no interaction by construction: a point a pixel wide is a target neither for the
mouse nor for a finger, and a focusable element in every cell of a table would break its walk from
the keyboard. If values on hover are needed, that is already
GrChartLine in a cell of its own or in a popover.
Install
npm i @feugene/granularity-chartsImport
import { GrSparkline } from '@feugene/granularity-charts/components/GrSparkline'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 2
Basic
<script setup lang="ts">
// `GrSparkline` подставляется авто-импортом (`unplugin-vue-components`).
/**
* Спарклайн отвечает на один вопрос: **куда оно движется**.
*
* Точные значения даёт число рядом, а форму ряда — линия: осей и подписей у неё
* нет намеренно, иначе она перестанет читаться боковым зрением за долю секунды,
* ради которой её и ставят. Читается слева направо: левый край — начало
* периода, правый — «сейчас». Маркера там нет и не нужно: линия и так упирается
* в правый край, а концы периода подписывает карточка.
*/
const signups = [980, 1010, 995, 1042, 1078, 1065, 1120, 1156, 1190, 1215, 1246, 1284]
const churn = [34, 33, 30, 31, 27, 24, 22, 23, 18, 15, 13, 11]
const latency = [128, 132, 126, 141, 138, 152, 147, 139, 144, 136]
/** Число на карточке — последнее значение ряда, а не отдельная константа: разойтись им нельзя. */
function current(row: number[]): string {
return row.at(-1)!.toLocaleString('ru-RU')
}
function delta(row: number[]): number {
const first = row[0]!
const last = row.at(-1)!
return Math.round(((last - first) / first) * 100)
}
</script>
<template>
<div class="grid gap-6">
<div class="grid gap-4 sm:grid-cols-2">
<!--
Карточка показателя — основной сценарий. Число отвечает «сколько»,
спарклайн — «как менялось», бейдж — «на сколько за период».
-->
<div class="rounded-[var(--gr-radius-lg)] border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
<div class="flex items-start justify-between gap-3">
<span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">Регистрации</span>
<GrBadge tone="success" size="sm">+{{ delta(signups) }}%</GrBadge>
</div>
<strong class="mt-1 block text-2xl [font-variant-numeric:tabular-nums]">{{ current(signups) }}</strong>
<div class="mt-3">
<GrSparkline :data="signups" />
</div>
<!-- Подписи концов: без них непонятно, где начало ряда, а где «сейчас». -->
<div class="mt-1 flex justify-between text-[length:var(--gr-control-text-2xs)] text-[var(--gr-muted-fg)]">
<span>12 недель назад</span>
<span>сейчас</span>
</div>
</div>
<div class="rounded-[var(--gr-radius-lg)] border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
<div class="flex items-start justify-between gap-3">
<span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">Отток</span>
<GrBadge tone="success" size="sm">{{ delta(churn) }}%</GrBadge>
</div>
<strong class="mt-1 block text-2xl [font-variant-numeric:tabular-nums]">{{ current(churn) }}</strong>
<div class="mt-3">
<!-- `area` уместна там, где важен объём под кривой, а не только её форма. -->
<GrSparkline :data="churn" variant="area" color="var(--gr-chart-2)" />
</div>
<div class="mt-1 flex justify-between text-[length:var(--gr-control-text-2xs)] text-[var(--gr-muted-fg)]">
<span>12 недель назад</span>
<span>сейчас</span>
</div>
</div>
</div>
<!--
Второй сценарий: спарклайн внутри строки текста. Ему не нужен ни контейнер,
ни замер — высоту задаёт токен, ширину контейнер.
-->
<p class="flex flex-wrap items-center gap-2 text-[length:var(--gr-control-text-sm)]">
<span class="text-[var(--gr-muted-fg)]">Отклик API</span>
<strong class="[font-variant-numeric:tabular-nums]">{{ current(latency) }} мс</strong>
<span
class="inline-block w-24"
style="--gr-sparkline-height: 1.25rem"
>
<GrSparkline :data="latency" />
</span>
<span class="text-[var(--gr-muted-fg)]">за последний час</span>
</p>
<p class="showcase-demo-text text-sm text-[var(--gr-muted-fg)]">
Осей и сетки у спарклайна нет намеренно: он про <strong>форму</strong>, а не про значения — точные числа стоят
рядом. Для скринридера форма превращается в текст: имя картинки собирается само, и у карточки «Отток» звучит как
«падение, от 34 до 11, минимум 11, максимум 34».
</p>
</div>
</template>Table
<script setup lang="ts">
import { computed } from 'vue'
/**
* Сценарий, ради которого спарклайн и существует: колонка «динамика» в таблице.
*
* Он ничего не замеряет и не держит слушателей, поэтому сотня строк стоит ровно
* сотню коротких `<svg>`. Здесь важна не отдельная линия, а **сравнение форм по
* вертикали**: глаз находит выбивающуюся строку раньше, чем прочитает числа.
*/
interface Row {
service: string
unit: string
trend: (number | null)[]
}
const rows: Row[] = [
{ service: 'API Gateway', unit: 'мс', trend: [128, 132, 126, 141, 138, 152, 147, 139, 144, 136] },
{ service: 'Auth', unit: 'мс', trend: [92, 90, 94, 91, 89, 93, 90, 88, 91, 87] },
{ service: 'Search', unit: 'мс', trend: [210, 224, 236, 251, 268, 279, 298, 312, 331, 348] },
// Пропуск — не ноль: сервис не отвечал, значения не было. Линия рвётся.
{ service: 'Storage', unit: 'мс', trend: [164, 158, null, null, 149, 152, 147, 143, 139, 134] },
{ service: 'Queue', unit: 'мс', trend: [46, 44, 47, 45, 43, 44, 42, 41, 43, 40] },
]
/** Тон линии — по смыслу: для времени отклика рост это ухудшение. */
function toneColor(trend: (number | null)[]): string {
const values = trend.filter((value): value is number => value !== null)
const change = (values.at(-1)! - values[0]!) / values[0]!
if (change > 0.05)
return 'var(--gr-danger)'
if (change < -0.05)
return 'var(--gr-success)'
return 'var(--gr-chart-1)'
}
const model = computed(() => rows.map((row) => {
const values = row.trend.filter((value): value is number => value !== null)
const first = values[0]!
const last = values.at(-1)!
return {
...row,
last,
change: Math.round(((last - first) / first) * 100),
color: toneColor(row.trend),
}
}))
</script>
<template>
<div class="grid gap-3">
<table class="w-full table-fixed border-collapse text-[length:var(--gr-control-text-sm)]">
<!--
Ширины заданы явно и колонка динамики — самая широкая. Иначе имя сервиса
забирает всё свободное место, линии прижимаются к числам и перестают
сравниваться по вертикали, то есть теряют единственный свой смысл.
-->
<colgroup>
<col class="w-40">
<col>
<col class="w-24">
<col class="w-20">
</colgroup>
<thead>
<tr class="border-b border-[var(--gr-brd)] text-left text-[var(--gr-muted-fg)]">
<th scope="col" class="py-2 pr-4 font-500">Сервис</th>
<th scope="col" class="py-2 pr-4 font-500">Отклик, 10 дней</th>
<th scope="col" class="py-2 pr-4 text-right font-500">Сейчас</th>
<th scope="col" class="py-2 text-right font-500">Δ</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in model"
:key="row.service"
class="border-b border-[var(--gr-brd)] last:border-0"
>
<th scope="row" class="py-2 pr-4 text-left font-500">{{ row.service }}</th>
<td class="py-2 pr-6 align-middle">
<!-- Высота задаётся токеном: в строке таблицы спарклайн обязан быть ниже, чем в карточке. -->
<GrSparkline
:data="row.trend"
:color="row.color"
style="--gr-sparkline-height: 1.75rem"
:aria-label="`${row.service}: динамика отклика за 10 дней`"
/>
</td>
<td class="py-2 pr-4 text-right [font-variant-numeric:tabular-nums]">
{{ row.last }} <span class="text-[var(--gr-muted-fg)]">{{ row.unit }}</span>
</td>
<td
class="py-2 text-right [font-variant-numeric:tabular-nums]"
:class="row.change > 0 ? 'text-[var(--gr-danger-text)]' : row.change < 0 ? 'text-[var(--gr-success-text)]' : 'text-[var(--gr-muted-fg)]'"
>
{{ row.change > 0 ? '+' : '' }}{{ row.change }}%
</td>
</tr>
</tbody>
</table>
<p class="showcase-demo-text text-sm text-[var(--gr-muted-fg)]">
Строка <strong>Search</strong> находится глазом раньше, чем читается: её форма выбивается из остальных. Это и есть
работа спарклайна в таблице — не показать значение, а показать, какую строку смотреть. У <strong>Storage</strong>
линия разорвана: два часа сервис не отвечал, и пропуск нарисован разрывом, а не нулём.
<br>
Важная оговорка: каждая линия нормирована по <strong>своему</strong> ряду, поэтому сравнивать между строками можно
формы, но не уровни — 40 мс и 348 мс займут одинаковую высоту. Уровень читается в колонке «Сейчас».
</p>
</div>
</template>Accessibility
- APG pattern
Клавиатуры нет вовсе: компонент неинтерактивен и объявляет себя role="img" со сводкой