Пропонуємо розробку інтерактивних зведених таблиць під ключ: оцінимо ваш проєкт безкоштовно за 1 день. Наші зведені таблиці (pivot table) забезпечують гнучку аналітику на сайті з агрегацією даних. Використовуємо drag-and-drop таблиці, серверну агрегацію на ClickHouse pivot, експорт Excel та інтерактивні таблиці для бізнес-аналітики. Завдяки серверній агрегації аналітика реального часу стає можливою навіть для 10 млн рядків – це в 5 разів швидше, ніж клієнтська агрегація з React pivot.
Чому готові бібліотеки не підходять для великих даних
Уявіть: інтернет-магазин з 10 млн замовлень. Менеджеру потрібно сформувати звіт по продажах за квартал з групуванням по категоріях та місяцях. Звичайний експорт в Excel займає 30 хвилин і навантажує базу. Зведена таблиця з серверною агрегацією віддає дані за 200 мс і дозволяє змінювати зрізи в реальному часі. На практиці готові бібліотеки не завжди справляються з таким навантаженням: react-pivottable гальмує на 200k+ рядків, AG Grid вимагає enterprise-ліцензії для серверного режиму, а Flexmonster не підтримує ad-hoc запити до реляційних баз. Ми комбінуємо бібліотеки та власну серверну агрегацію, щоб закрити ці прогалини. Економія на ліцензіях робить такий підхід дешевшим, ніж enterprise-ліцензії на кожного розробника.
Wikipedia визначає зведену таблицю як інструмент аналізу даних з агрегацією по вимірах. В комерційних проєктах часто потрібна кастомізація, яку готові рішення не забезпечують.
Як ми будуємо серверну агрегацію для мільйонів рядків?
Для великих даних конфігурація pivot відправляється на сервер. Використовуємо ClickHouse або PostgreSQL з індексами, матеріалізованими представленнями та партиціонуванням. Кожна зміна осей надсилає запит, сервер повертає агрегати за 100-500 мс. Кешуємо результати за ключем конфігурації. Нижче — приклад генерації SQL з конфігу.
// Сервер — генерація SQL з конфігу
function buildPivotQuery(config: PivotConfig, dateRange: [Date, Date]): string {
const rowsExpr = config.rows.map(r => `"${r}"`).join(', ');
const colsExpr = config.cols.map(c => `"${c}"`).join(', ');
const valExpr = config.values[0]; // спрощення
const aggExpr = {
sum: `SUM("${valExpr}")`,
count: `COUNT(*)`,
avg: `AVG("${valExpr}")::numeric(18,2)`,
min: `MIN("${valExpr}")`,
max: `MAX("${valExpr}")`,
}[config.aggFn];
return `
SELECT ${rowsExpr}, ${colsExpr}, ${aggExpr} AS value
FROM events
WHERE created_at BETWEEN $1 AND $2
GROUP BY ${rowsExpr}, ${colsExpr}
ORDER BY ${rowsExpr}, ${colsExpr}
`;
}
Покрокова інструкція
- Аналіз структури даних. Визначаємо поля для групування (рядки, стовпці) та міри (сума, середнє).
- Проектування SQL-запитів. Генеруємо динамічні запити з GROUP BY.
- Налаштування ClickHouse/PostgreSQL. Створюємо індекси, налаштовуємо кешування результатів.
- Інтеграція з клієнтом. По кожній зміні конфігу надсилаємо запит на сервер.
Порівняння клієнтської та серверної агрегації
| Характеристика | Клієнтська | Серверна |
|---|---|---|
| Макс. записів | ~200k | Без обмежень |
| Час відгуку | Миттєво | 100–500 мс |
| Вартість інфраструктури | Низька | Вища (сервер + кеш) |
| Гнучкість | Тільки попередньо завантажені дані | Будь-які SQL-запити |
| Масштабованість | Обмежена браузером | До мільярда рядків |
Серверна агрегація в 5 разів швидша для мільйонів рядків, але клієнтська в 5 разів швидша при малих даних. Вибір залежить від обсягу та вимог до інтерактивності.
Як реалізувати клієнтську агрегацію: покрокова інструкція
- Фільтрація даних — залишаємо лише записи, що відповідають встановленим фільтрам.
- Групування — визначаємо ключі для рядків та стовпців на основі значень вибраних полів.
- Агрегація — обчислюємо значення для кожної комірки (сума, кількість, середнє, мінімум, максимум).
- Виведення таблиці — рендеримо HTML-таблицю з підсумками та фіксованою шапкою.
Повний код функції агрегації (TypeScript)
type AggregateFunction = 'sum' | 'count' | 'avg' | 'min' | 'max';
interface PivotConfig {
rows: string[];
cols: string[];
values: string[];
aggFn: AggregateFunction;
filters: Record<string, string[]>;
}
interface PivotResult {
rowKeys: string[][];
colKeys: string[][];
data: Map<string, Map<string, number>>;
}
function computePivot(rawData: Record<string, any>[], config: PivotConfig): PivotResult {
const { rows, cols, values, aggFn, filters } = config;
const filtered = rawData.filter(row =>
Object.entries(filters).every(([field, allowed]) =>
!allowed.length || allowed.includes(String(row[field]))
)
);
const rowKeySet = new Set<string>();
const colKeySet = new Set<string>();
const accumulator = new Map<string, Map<string, number[]>>();
filtered.forEach(row => {
const rowKey = rows.map(r => String(row[r] ?? '(пусто)')).join('||');
const colKey = cols.map(c => String(row[c] ?? '(пусто)')).join('||');
rowKeySet.add(rowKey);
colKeySet.add(colKey);
const numVal = values.reduce((sum, v) => sum + (Number(row[v]) || 0), 0);
if (!accumulator.has(rowKey)) accumulator.set(rowKey, new Map());
const colMap = accumulator.get(rowKey)!;
if (!colMap.has(colKey)) colMap.set(colKey, []);
colMap.get(colKey)!.push(numVal);
});
const aggregated = new Map<string, Map<string, number>>();
accumulator.forEach((colMap, rowKey) => {
const row = new Map<string, number>();
colMap.forEach((vals, colKey) => {
let result: number;
switch (aggFn) {
case 'sum': result = vals.reduce((a, b) => a + b, 0); break;
case 'count': result = vals.length; break;
case 'avg': result = vals.reduce((a, b) => a + b, 0) / vals.length; break;
case 'min': result = Math.min(...vals); break;
case 'max': result = Math.max(...vals); break;
}
row.set(colKey, result);
});
aggregated.set(rowKey, row);
});
return {
rowKeys: Array.from(rowKeySet).sort().map(k => k.split('||')),
colKeys: Array.from(colKeySet).sort().map(k => k.split('||')),
data: aggregated,
};
}
Повний код компонента PivotTable (React+TypeScript)
function PivotTable({ result, config, format }: {
result: PivotResult;
config: PivotConfig;
format?: (val: number) => string;
}) {
const fmt = format ?? (v => v.toLocaleString('uk-UA'));
const rowTotals = result.rowKeys.map(rk => {
const rowKey = rk.join('||');
let total = 0;
result.colKeys.forEach(ck => {
total += result.data.get(rowKey)?.get(ck.join('||')) ?? 0;
});
return total;
});
const grandTotal = rowTotals.reduce((a, b) => a + b, 0);
return (
<div className="overflow-auto max-h-[600px]">
<table className="text-sm border-collapse w-full">
<thead className="sticky top-0 bg-white z-10">
<tr>
{config.rows.map(r => (
<th key={r} className="border px-3 py-2 text-left bg-gray-50 font-medium">{r}</th>
))}
{result.colKeys.map(ck => (
<th key={ck.join('/')} className="border px-3 py-2 text-right bg-gray-50 font-medium whitespace-nowrap">
{ck.join(' / ')}
</th>
))}
<th className="border px-3 py-2 text-right bg-blue-50 font-semibold">Разом</th>
</tr>
</thead>
<tbody>
{result.rowKeys.map((rk, ri) => {
const rowKey = rk.join('||');
return (
<tr key={rowKey} className="hover:bg-gray-50">
{rk.map((label, i) => (
<td key={i} className="border px-3 py-1.5 font-medium">{label}</td>
))}
{result.colKeys.map(ck => {
const val = result.data.get(rowKey)?.get(ck.join('||'));
return (
<td key={ck.join('/')} className="border px-3 py-1.5 text-right tabular-nums">
{val != null ? fmt(val) : '—'}
</td>
);
})}
<td className="border px-3 py-1.5 text-right tabular-nums font-medium bg-blue-50">
{fmt(rowTotals[ri])}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="font-semibold bg-gray-100">
<td colSpan={config.rows.length} className="border px-3 py-2">Разом</td>
{result.colKeys.map(ck => {
const colTotal = result.rowKeys.reduce((sum, rk) => {
return sum + (result.data.get(rk.join('||'))?.get(ck.join('||')) ?? 0);
}, 0);
return (
<td key={ck.join('/')} className="border px-3 py-2 text-right tabular-nums">{fmt(colTotal)}</td>
);
})}
<td className="border px-3 py-2 text-right tabular-nums bg-blue-100">{fmt(grandTotal)}</td>
</tr>
</tfoot>
</table>
</div>
);
}
Експорт в Excel
Використовуємо exceljs для генерації .xlsx на клієнті:
import ExcelJS from 'exceljs';
async function exportToExcel(result: PivotResult, config: PivotConfig) {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Зведена таблиця');
const headers = [...config.rows, ...result.colKeys.map(k => k.join(' / ')), 'Разом'];
ws.addRow(headers).font = { bold: true };
result.rowKeys.forEach(rk => {
const rowKey = rk.join('||');
const row = [...rk];
result.colKeys.forEach(ck => {
row.push(String(result.data.get(rowKey)?.get(ck.join('||')) ?? ''));
});
ws.addRow(row);
});
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'pivot.xlsx';
a.click();
}
Як вибрати між клієнтською та серверною агрегацією?
Якщо дані до 200k рядків і не вимагають складної фільтрації — клієнтський підхід швидший і дешевший. Для мільйонів рядків та ad-hoc запитів — серверна агрегація обов'язкова. Ми допомагаємо клієнтам підібрати оптимальну архітектуру.
Що входить у роботу
- Розробка клієнтського UI з drag-and-drop (React, TypeScript, Tailwind)
- Серверна агрегація на ClickHouse/PostgreSQL з кешуванням
- Експорт в Excel/CSV, друк
- Документація коду та архітектури
- Доступ до репозиторію вихідних кодів
- Навчання команди роботі з інструментом
- Технічна підтримка 3 місяці після запуску
Етапи розробки та орієнтовні терміни
| Етап | Тривалість | Опис |
|---|---|---|
| Аналіз даних | 2-3 дні | Вивчення структури даних, вимог до метрик та фільтрації |
| Проектування схеми агрегації | 1-2 дні | Визначення полів, мір, індексів для ClickHouse/PostgreSQL |
| Розробка клієнтського UI | 1-2 тижні | Реалізація drag-and-drop конфігуратора, таблиці з підсумками та sticky header |
| Серверна агрегація (якщо потрібна) | 1-2 тижні | Налаштування ClickHouse, кешування, генерація SQL |
| Експорт в Excel/CSV | 2-3 дні | Інтеграція з exceljs, перевірка форматування |
| Навантажувальне тестування | 2-3 дні | Перевірка на 10 млн рядків, оптимізація N+1 запитів |
Клієнтський pivot до 50k рядків з drag-and-drop конфігуратором та експортом в Excel — 2–3 тижні. Серверний режим з ClickHouse або PostgreSQL, кешуванням результатів та підтримкою кількох значень — додатково 1–2 тижні.
Чому варто обрати нас?
Маємо 7+ років досвіду в розробці аналітичних систем, реалізовано 30+ проєктів аналітики, сертифіковані фахівці з ClickHouse та PostgreSQL. Гарантуємо якість та підтримку після запуску. Зв'яжіться з нами для оцінки проєкту — напишіть на пошту або в телеграм.







