GrChartHeatmap

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

Берут, когда матрица удержания по когортам.

Когда брать

  • матрица удержания по когортам — строки когорты, колонки смещения: сразу видно, на каком месяце кривая выполаживается;
  • два категориальных измерения и одна величина — часы × дни недели, сервис × версия, регион × канал;
  • важна форма, а не точные числа — пятно и градиент читаются быстрее двадцати ячеек с цифрами;
  • матрица разреженаnull оставляет ячейку пустой, и «ещё не наступило» видно как отсутствие, а не как ноль;
  • отклонение от нормы в обе стороны — расходящаяся шкала красит недобор и перебор разными ролями вокруг середины.

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

НужноБерите
Сравнить величины одного измеренияGrChartBar
Показать ход величины во времениGrChartLine
Показать точные числа, которые будут читать и сортироватьGrDataTable
Показать профиль по нескольким осямGrChartRadar

`null` — не ноль и не минимум шкалы

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

Строки разной длины дополняются null справа, а не нулями: матрица когорт разрежена по построению.

Шкала — одна роль темы, а не пять цветов

Цвет ячейки считается color-mix по роли (highColor, а у расходящейся шкалы ещё lowColor и midColor). Пять подобранных цветов пришлось бы подбирать заново под тёмную тему и заново же — под вторую теплокарту на соседней странице; роль подстраивается сама.

steps квантует долю: 5 (по умолчанию) даёт пять ступеней, 0 — непрерывный градиент. Края шкалы в обоих режимах совпадают, различаются середины.

Расходящаяся шкала нормируется на больший из отступов от середины — так она симметрична по построению, а не по совпадению данных. У минимума при этом остаётся заметная примесь краски: без неё он был бы неотличим от пустой ячейки.

Легенда расшифровывает шкалу, а не перечисляет категории

Полоса с подписями границ домена. Без неё цвет нечем прочитать: у матрицы нет оси значений, и «насыщеннее» само по себе не значит ничего.

Клавиатура двумерная

←→ меняют колонку, ↑↓ — строку, Home/End уводят в край строки, PageUp/PageDown — в край колонки. Одна остановка Tab на всю карту.

Ни одна ось не кольцуется. Перескок с конца строки на начало следующей дезориентирует: читатель теряет, в какой он строке, а сказать ему об этом нечем — объявляется только содержимое ячейки.

Скрытая таблица здесь ценнее всего

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

Границы

Компонент не кластеризует, не сортирует строки и колонки и не рисует дендрограмму: порядок задаёт потребитель, потому что только он знает, что здесь означает «похожие». Неравномерной сетки тоже нет — ячейки одного размера, иначе площадь начала бы кодировать второй показатель, о котором читателю не сказали.

Установка

npm i @feugene/granularity-charts

Импорт

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

API

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

Примеры 3

Cohorts

Cohorts
<script setup lang="ts">
/**
 * Матрица удержания: строки — когорты, колонки — месяц после регистрации.
 *
 * Разреженность здесь не дефект данных, а их природа: у сентябрьской когорты
 * четвёртого месяца ещё не было. Такие ячейки остаются пустыми, а не нулевыми —
 * «ещё не наступило» и «удержание ноль» это разные утверждения.
 */
const yLabels = ['Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь']
const xLabels = ['M0', 'M1', 'M2', 'M3', 'M4']

const values = [
  [100, 64, 48, 41, 38],
  [100, 61, 45, 39],
  [100, 67, 52],
  [100, 58],
  [100],
]
</script>

<template>
  <div class="grid gap-3">
    <span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Удержание по когортам, %
    </span>

    <GrChartHeatmap
      :values="values"
      :x-labels="xLabels"
      :y-labels="yLabels"
      :domain="[0, 100]"
      :height="240"
      aria-label="Удержание по когортам"
    />

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Цвет — <strong>примесь роли темы</strong> через <code>color-mix</code>, а не палитра из пяти
      подобранных цветов: пять пришлось бы подбирать заново под тёмную тему. Клавиатура двумерная и
      не кольцуется ни по одной оси: перескок с конца строки на начало следующей дезориентирует.
    </p>
  </div>
</template>

Incidents

Incidents
<script setup lang="ts">
/**
 * Тридцать сервисов на восемьдесят дней: доля неуспешных ответов.
 *
 * Матрица такого размера — то, ради чего теплокарта и существует. Тот же срез
 * тридцатью линиями превращается в клубок, из которого не читается ничего;
 * здесь сбой виден как **полоса**, и её направление сразу говорит, что
 * случилось: вертикальная — упала инфраструктура и задело всех, горизонтальная —
 * сломался один сервис и его чинили две недели.
 *
 * Данные детерминированные: тот же рисунок при каждой отрисовке, без часов и
 * без случайных чисел.
 */
const SERVICES = [
  'api-gateway',
  'auth',
  'billing',
  'cart',
  'catalog',
  'checkout',
  'cms',
  'delivery',
  'email',
  'events',
  'exports',
  'payments',
  'feed',
  'files',
  'geo',
  'identity',
  'images',
  'imports',
  'inventory',
  'invoices',
  'loyalty',
  'media',
  'notify',
  'orders',
  'pricing',
  'search',
  'sessions',
  'shipping',
  'support',
  'webhooks',
]

const DAYS = 80
const START = new Date(2026, 5, 1)
const MONTHS = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']

/** Детерминированный шум 0…1: то же значение при каждом вызове. */
function noise(service: number, day: number): number {
  const value = Math.sin(service * 12.9898 + day * 78.233) * 43758.5453

  return value - Math.floor(value)
}

const values = SERVICES.map((_, service) => {
  // Свой уровень шума у каждого сервиса: одни спокойны годами, другие сыплют
  // ошибками всегда. Без этого поле выходит однородным, а так у матрицы
  // появляется горизонтальная текстура — как у настоящей телеметрии.
  const level = 0.8 + noise(service, 7) * 1.3

  return Array.from({ length: DAYS }, (_, day) => {
    // Выходные тише буднего дня: отсюда недельный ритм, по которому глаз сам
    // находит вертикальные полосы, не считая дней.
    const weekend = day % 7 === 5 || day % 7 === 6 ? 0.5 : 1
    let rate = level * weekend * (0.65 + noise(service, day))

    if (day === 23 || day === 24)
      rate += 1.6 + noise(service, 991) * 1.1
    if (service === 11 && day >= 40 && day <= 53)
      rate += 2.8
    if (service === 25)
      rate += (day / (DAYS - 1)) ** 1.6 * 2.4
    if (service === 7 && day === 62)
      rate += 3

    return Number(rate.toFixed(2))
  })
})

function dayLabel(day: number): string {
  const date = new Date(START.getFullYear(), START.getMonth(), START.getDate() + day)

  return `${date.getDate()} ${MONTHS[date.getMonth()]}`
}

// Подпись на каждый десятый день: восемьдесят подписей подряд слиплись бы в
// серую полосу. Пустая строка — это отсутствие подписи, а не пустая подпись.
const xLabels = Array.from({ length: DAYS }, (_, day) => (day % 10 === 0 ? dayLabel(day) : ''))
</script>

<template>
  <div class="grid gap-3">
    <span class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Доля неуспешных ответов, % · {{ SERVICES.length }} сервисов × {{ DAYS }} дней
    </span>

    <GrChartHeatmap
      :values="values"
      :x-labels="xLabels"
      :y-labels="SERVICES"
      :domain="[0, 5]"
      :cell-gap="1"
      :height="520"
      show-legend
      aria-label="Доля неуспешных ответов по сервисам за восемьдесят дней"
    />

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Две тысячи четыреста ячеек читаются как одна картина: <strong>вертикальная полоса</strong> в
      конце июня — сбой инфраструктуры, задело все сервисы разом; <strong>горизонтальная</strong> у
      <code>payments</code> — две недели деградации, пока чинили; <code>search</code> уходит в
      красное <strong>плавно</strong>, и это не инцидент, а регрессия, которую замечают поздно.
      Недельный ритм даёт текстуру: по выходным нагрузки меньше.
    </p>

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Числа в ячейках гаснут сами: при <code>showValues: 'auto'</code> они появляются, только когда
      ячейка достаточно широка, — иначе подпись была бы нечитаемой и мешала бы цвету. Подписи дней
      прорежены до каждого десятого, а скрытая таблица данных здесь полная: строк в ней тридцать по
      числу сервисов, и потолок (<code>dataTableMaxRows</code>) до неё не дотягивается.
    </p>
  </div>
</template>

Scale

Scale
<script setup lang="ts">
import { ref } from 'vue'

/**
 * Расходящаяся шкала берут, когда важно отклонение в обе стороны: недобор и
 * перебор красятся разными ролями вокруг середины.
 *
 * Ступени против непрерывной шкалы — вопрос того, читают график как карту зон
 * или как градиент.
 */
const xLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
const yLabels = ['Утро', 'День', 'Вечер', 'Ночь']

const values = [
  [12, 8, -4, 6, 14, -22, -31],
  [24, 19, 16, 22, 28, -8, -18],
  [6, 11, 9, 14, 32, 21, 4],
  [-14, -12, -16, -11, -2, 9, -6],
]

const steps = ref(5)
</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>

      <GrSegmented
        v-model="steps"
        size="sm"
        :options="[
          { value: 5, label: '5 ступеней' },
          { value: 0, label: 'Непрерывно' },
        ]"
        aria-label="Шкала цвета"
      />
    </div>

    <GrChartHeatmap
      :values="values"
      :x-labels="xLabels"
      :y-labels="yLabels"
      scale="diverging"
      :midpoint="0"
      :steps="steps"
      :height="220"
      show-values
      aria-label="Отклонение нагрузки от нормы"
    />

    <p class="text-[length:var(--gr-control-text-sm)] text-[var(--gr-muted-fg)]">
      Расходящаяся шкала нормируется на <strong>больший</strong> из отступов от середины — так она
      симметрична по построению, а не по совпадению данных. Контраст подписи в ячейке считается от
      доли примеси: измерить итоговый цвет без DOM нечем.
    </p>
  </div>
</template>

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