GrSparkline

Package: @feugene/granularity-chartscompanionGroup: misc

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 neededsummary adds 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

NeedTake
Read values off the axes rather than the silhouetteGrChartLine
Show volume rather than the courseGrChartArea
Compare categories with one anotherGrChartBar
Show a share of a wholeGrProgressBar

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-charts

Import

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

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

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" со сводкой

Full keyboard contract of the package

Component documentationAll components