GrChartHeatmap

Package: @feugene/granularity-chartscompanionGroup: misc

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • a retention matrix by cohort — the rows are cohorts, the columns offsets: it is immediately visible on which month the curve flattens out;
  • two categorical dimensions and one quantity — hours × days of the week, service × version, region × channel;
  • the shape matters rather than the exact numbers — a patch and a gradient read faster than twenty cells with figures;
  • the matrix is sparsenull leaves a cell empty, and “has not happened yet” is visible as an absence rather than as a zero;
  • a deviation from the norm in both directions — a diverging scale colours a shortfall and an excess with different roles around the middle.

When to take something else

NeedTake
Compare the quantities of one dimensionGrChartBar
Show the course of a quantity over timeGrChartLine
Show exact numbers that will be read and sortedGrDataTable
Show a profile across several axesGrChartRadar

`null` is neither a zero nor the minimum of the scale

A missing cell is not filled at all, gets a dash in the hidden table and does not enter the domain of the scale. For cohorts that matters fundamentally: “the month has not come yet” and “retention is zero per cent” are different statements, and colouring the first as the second would mean drawing a complete washout where there is simply no data.

Rows of different lengths are padded with null on the right rather than with zeros: a cohort matrix is sparse by construction.

The scale is one role of the theme rather than five colours

The colour of a cell is computed with color-mix from a role (highColor, and in a diverging scale also lowColor and midColor). Five hand-picked colours would have to be picked again for the dark theme and again for the second heat map on a neighbouring page; a role adjusts itself.

steps quantises the share: 5 (the default) gives five steps, 0 a continuous gradient. The edges of the scale coincide in both modes, and the middles differ.

A diverging scale is normalised to the larger of the offsets from the middle — that way it is symmetric by construction rather than by a coincidence in the data. The minimum meanwhile keeps a noticeable admixture of paint: without it, it would be indistinguishable from an empty cell.

The legend decodes the scale rather than enumerating the categories

A bar with the labels of the bounds of the domain. Without it there is nothing to read the colour by: a matrix has no value axis, and “more saturated” on its own means nothing.

The keyboard is two-dimensional

←→ change the column, ↑↓ the row, Home/End take you to the edge of a row, and PageUp/PageDown to the edge of a column. One Tab stop for the whole map.

Neither axis wraps around. A jump from the end of a row to the beginning of the next one disorients: the reader loses track of which row they are in, and there is nothing to tell them — only the content of a cell is announced.

The hidden table is the most valuable thing here

It has real headings for the rows and the columns, and that is not a formality: a visual heat map without a table is unreadable entirely rather than merely less convenient. Colour tells a blind user nothing, and there may be no values in the drawing — the labels in the cells appear only where they fit.

Limits

The component does not cluster, does not sort the rows and the columns and does not draw a dendrogram: the order is set by the consumer, because only they know what “similar” means here. There is no non-uniform grid either — the cells are of one size, otherwise the area would start encoding a second metric the reader was not told about.

Install

npm i @feugene/granularity-charts

Import

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

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

Component documentationAll components