GrChartHeatmap
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 sparse —
nullleaves 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
| Need | Take |
|---|---|
| Compare the quantities of one dimension | GrChartBar |
| Show the course of a quantity over time | GrChartLine |
| Show exact numbers that will be read and sorted | GrDataTable |
| Show a profile across several axes | GrChartRadar |
`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.
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-chartsImport
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
<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
<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
<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>