GrChartHeatmap
Берут, когда матрица удержания по когортам.
Когда брать
- матрица удержания по когортам — строки когорты, колонки смещения: сразу видно, на каком месяце кривая выполаживается;
- два категориальных измерения и одна величина — часы × дни недели, сервис × версия, регион × канал;
- важна форма, а не точные числа — пятно и градиент читаются быстрее двадцати ячеек с цифрами;
- матрица разрежена —
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
<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>