Ви витрачаєте години на аналіз таблиць із тисячами рядків, намагаючись знайти закономірності. Теплова карта стискає цю інформацію в один екран: червоні зони — піки активності, зелені — низька. За дві секунди ви бачите, що найчастіше замовлення роблять у п'ятницю ввечері. Але вибір правильного стеку — непросте завдання: SVG гальмує на 10 000+ точках, Canvas вимагає ручного hit-testing, а інтеграція з бекендом може затягнутися. Ми вирішуємо це завдання з нуля: від підготовки даних до публікації на продакшені. Наші інженери використовують сучасний стек: React, D3.js, Leaflet, Canvas — і інтегрують ці рішення з вашою бекенд-системою. Результат: ви бачите патерни там, де таблиці вимагають хвилин аналізу.
Які завдання вирішуємо?
Теплова карта — універсальний інструмент, але важливо вибрати правильний тип візуалізації. Розглянемо чотири ключові варіанти.
Часова активність
Рядки — дні тижня, стовпці — години. Класика для показу, коли відбуваються події (замовлення, візити, інциденти). GitHub contribution graph — саме така карта. Допомагає виявити пікові години навантаження і спланувати ресурси. Приклад: для інтернет-магазину ми побудували heatmap активності по днях і годинах, що дозволило виявити пік замовлень о 20:00 по п'ятницях. Після запуску таргетованих push-сповіщень конверсія зросла на 15%.
Матриця кореляцій
N×N комірок, значення від -1 до 1. Використовується в фінансах і ML для аналізу залежностей між змінними. Наприклад, зв'язок між запуском реклами та конверсією.
Географічний heatmap
Накладання щільності точок на карту. Вирішується через Leaflet + leaflet.heat або Mapbox. Корисний для логістики, ритейлу, аналізу відвідуваності.
Cohort retention
Рядки — когорти (місяць реєстрації), стовпці — періоди утримання. Один із ключових інструментів product analytics. Дозволяє зрозуміти, як довго користувачі залишаються активними після реєстрації. Ми фіксуємо домен кольорової шкали від 0 до 100%, щоб уникнути викривлень.
Зведення типів heatmap:
| Тип | Дані | Приклад використання |
|---|---|---|
| Часова активність | (день, година, кількість) | GitHub contribution graph |
| Матриця кореляцій | (змінна, змінна, коефіцієнт) | Фінансові кореляції |
| Географічний | (широта, довгота, вага) | Розподіл клієнтів по точках |
| Cohort retention | (когорта, період, %) | Утримання користувачів після реєстрації |
Як вибрати між SVG і Canvas?
Продуктивність — головний критерій. D3.js documentation рекомендує Canvas для великих обсягів даних. SVG-heatmap із 10 000+ комірок гальмує: кожен елемент — окремий DOM-вузол. Canvas рендерить швидше, але вимагає ручного hit-testing для tooltip. Ми вибираємо технологію під задачу.
| Характеристика | SVG | Canvas |
|---|---|---|
| Кількість комірок | до 3 000 | 10 000+ |
| Tooltip | вбудований | ручний hit-test |
| Масштабування | без втрат | з втратою |
| Розробка | швидше | складніше |
Для великих обсягів використовуємо Canvas. Приклад hit-testing:
useEffect(() => { const canvas = canvasRef.current!; const ctx = canvas.getContext('2d')!; const dpr = window.devicePixelRatio; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; ctx.scale(dpr, dpr); const cellW = iw / cols.length; const cellH = ih / rows.length; data.forEach(d => { const x = margin.left + cols.indexOf(d.col) * cellW; const y = margin.top + rows.indexOf(d.row) * cellH; ctx.fillStyle = colorScale(d.value); ctx.fillRect(x + 1, y + 1, cellW - 2, cellH - 2); }); }, [data]); Реалізація через D3.js
import { useEffect, useRef } from 'react'; import * as d3 from 'd3'; interface HeatmapCell { row: string; col: string; value: number; } interface HeatmapProps { data: HeatmapCell[]; rows: string[]; cols: string[]; colorScheme?: 'blues' | 'reds' | 'greens' | 'rdylgn'; width?: number; height?: number; } export function Heatmap({ data, rows, cols, colorScheme = 'blues', width = 700, height = 400 }: HeatmapProps) { const svgRef = useRef<SVGSVGElement>(null); const margin = { top: 30, right: 20, bottom: 60, left: 80 }; const iw = width - margin.left - margin.right; const ih = height - margin.top - margin.bottom; useEffect(() => { if (!svgRef.current) return; const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`); const xScale = d3.scaleBand().domain(cols).range([0, iw]).padding(0.05); const yScale = d3.scaleBand().domain(rows).range([0, ih]).padding(0.05); const colorInterpolators = { blues: d3.interpolateBlues, reds: d3.interpolateReds, greens: d3.interpolateGreens, rdylgn: d3.interpolateRdYlGn, }; const extent = d3.extent(data, d => d.value) as [number, number]; const colorScale = d3.scaleSequential() .domain(extent) .interpolator(colorInterpolators[colorScheme]); // Axes g.append('g') .attr('transform', `translate(0,${ih})`) .call(d3.axisBottom(xScale).tickSize(0)) .select('.domain').remove(); g.append('g') .call(d3.axisLeft(yScale).tickSize(0)) .select('.domain').remove(); // Tooltip const tooltip = d3.select('body').append('div') .style('position', 'absolute') .style('display', 'none') .style('background', 'rgba(0,0,0,0.8)') .style('color', '#fff') .style('padding', '6px 10px') .style('border-radius', '4px') .style('font-size', '12px') .style('pointer-events', 'none'); // Cells g.selectAll('.cell') .data(data) .join('rect') .attr('class', 'cell') .attr('x', d => xScale(d.col)!) .attr('y', d => yScale(d.row)!) .attr('width', xScale.bandwidth()) .attr('height', yScale.bandwidth()) .attr('fill', d => d.value == null ? '#f0f0f0' : colorScale(d.value)) .attr('rx', 2) .on('mouseover', (event, d) => { tooltip .style('display', 'block') .style('left', `${event.pageX + 12}px`) .style('top', `${event.pageY - 28}px`) .html(`<strong>${d.row} / ${d.col}</strong><br/>${d3.format(',.2f')(d.value)}`); }) .on('mouseout', () => tooltip.style('display', 'none')); return () => { tooltip.remove(); }; }, [data, rows, cols, colorScheme]); return <svg ref={svgRef} width={width} height={height} />; } Легенда з градієнтом
function addColorLegend( svg: d3.Selection<SVGSVGElement, unknown, null, undefined>, colorScale: d3.ScaleSequential<string>, x: number, y: number, width = 200, height = 12 ) { const defs = svg.append('defs'); const gradientId = `legend-gradient-${Math.random().toString(36).slice(2)}`; const gradient = defs.append('linearGradient').attr('id', gradientId); gradient.append('stop').attr('offset', '0%').attr('stop-color', colorScale(colorScale.domain()[0])); gradient.append('stop').attr('offset', '100%').attr('stop-color', colorScale(colorScale.domain()[1])); const legendG = svg.append('g').attr('transform', `translate(${x},${y})`); legendG.append('rect') .attr('width', width) .attr('height', height) .style('fill', `url(#${gradientId})`); const legendScale = d3.scaleLinear() .domain(colorScale.domain()) .range([0, width]); legendG.append('g') .attr('transform', `translate(0,${height})`) .call(d3.axisBottom(legendScale).ticks(4).tickFormat(d3.format(',.0f'))); } Cohort retention heatmap
Частковий випадок з особливою логікою — значення по діагоналі від 0% до 100%:
interface CohortRow { cohort: string; // "Jan 2024" periods: (number | null)[]; // retention % по періодах } function prepareCohortData(cohorts: CohortRow[]): HeatmapCell[] { return cohorts.flatMap((row, rowIdx) => row.periods.map((value, colIdx) => ({ row: row.cohort, col: `Period ${colIdx}`, value: value ?? 0, isEmpty: value === null, })) ).filter(d => !d.isEmpty); } Для retention colorScale краще використовувати d3.interpolateRdYlGn — червоний для низьких значень, зелений для високих. Домен фіксуємо [0, 100], а не від min до max, інакше візуалізація вводить в оману.
Чому фіксований домен важливий?
Якщо домен динамічний, різниця між 80% і 90% буде показана яскраво, хоча обидві цифри високі. Фіксований домен [0,100] дає об'єктивну картину утримання.
Як інтегрувати heatmap з вашим API?
Для великих часових діапазонів дані агрегуються на сервері. Використовуємо PostgreSQL і Redis для кешування, що знижує навантаження на API. Наприклад, для активності по днях тижня і годинах застосовуємо SQL-запит:
SELECT EXTRACT(DOW FROM created_at)::int AS dow, EXTRACT(HOUR FROM created_at)::int AS hour, COUNT(*) AS events FROM user_events WHERE created_at > NOW() - INTERVAL '90 days' AND user_id = $1 GROUP BY 1, 2 ORDER BY 1, 2; Результат передається по API, а порожні комірки заповнюються нулями на сервері. Ми інтегруємося з будь-яким бекендом: Laravel, Node.js, Python. При необхідності налаштовуємо оновлення в реальному часі через WebSocket.
Що входить в роботу?
- Документація API та архітектури рішення
- Вихідний код з коментарями
- Адаптація під вашу CMS (WordPress, Drupal, Strapi)
- Інструкція з подальшої підтримки
- Гарантія 1 місяць на приховані помилки
Строки
Базовий heatmap з tooltip і легендою — 1–2 дні. Cohort retention з правильною підготовкою даних — 3–5 днів. Географічний heatmap — окрема оцінка. Вартість розраховується індивідуально.
Наша компанія понад 5 років на ринку, реалізувала 30+ проєктів з heatmap. Досвід з Core Web Vitals гарантує, що ваша карта не сповільнить сайт. Замовте консультацію — наші інженери проаналізують ваші дані та запропонують оптимальне рішення. Зв'яжіться з нами, щоб отримати розрахунок під ключ.







