GrSparkline

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

Берут, когда тренд в ячейке таблицы.

Когда брать

  • тренд в ячейке таблицы — колонка «динамика» рядом с числом: направление видно, точные значения читаются из соседней колонки;
  • тренд в карточке показателя — под крупным числом GrStatistic, чтобы «1 240» получило историю;
  • много графиков на одной странице — компонент ничего не замеряет и не держит слушателей, поэтому сотня штук на экране ничего не стоит;
  • нужен только силуэтsummary добавляет минимум, максимум и последнее значение словами для скринридера, не занимая места на экране.

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

НужноБерите
Читать значения по осям, а не силуэтGrChartLine
Показать объём, а не ходGrChartArea
Сравнить категории между собойGrChartBar
Показать долю от целогоGrProgressBar

Осей нет намеренно

Без осей у линии нет масштаба, и сравнивать две спарклайны между собой нельзя: каждая нормируется по своему ряду, поэтому одинаковый на вид подъём в двух строках таблицы может означать плюс два процента и плюс двести. Спарклайн отвечает «росло или падало» — на «насколько» отвечает соседняя колонка с числом.

Длинный ряд сокращается сам

Холст спарклайна фиксирован (viewBox шириной сто единиц), поэтому бюджет вершин известен заранее и замерять нечего: ряд длиннее бюджета прореживается по LTTB без единого пропа. Сотня спарклайнов в колонке таблицы перестаёт складывать сотню тысяч вершин ради рисунка шириной в пару сантиметров, а форма и одиночные выбросы остаются на месте — ../model.md, раздел «Прореживание — проекция, а не данные».

Ни курсора, ни клавиатуры

Интерактива у компонента нет по построению: точка шириной в пиксель не является целью ни для мыши, ни для пальца, а фокусируемый элемент в каждой ячейке таблицы сломал бы её обход с клавиатуры. Нужны значения по наведению — это уже GrChartLine в отдельной ячейке или в поповере.

Установка

npm i @feugene/granularity-charts

Импорт

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

API

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

Примеры 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>

Доступность

Паттерн APG
Клавиатуры нет вовсе: компонент неинтерактивен и объявляет себя role="img" со сводкой
Клавиши

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

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