pivotgrid-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * cache-manager.js
3
+ *
4
+ * Manages the dimension cache UI:
5
+ * - Renders dimension chips with cached/uncached state
6
+ * - Shows a fill meter and status label
7
+ * - Validates row count before adding a dimension to cache
8
+ * - Triggers cache refresh via the provider
9
+ */
10
+ class CacheManager {
11
+
12
+ /**
13
+ * @param {object} options
14
+ * @param {object} options.provider — data provider (RestProvider or ArrayProvider)
15
+ * @param {string[]} options.dimensions — full list of dimensions
16
+ * @param {number} options.maxCachedRows — row limit for the cache
17
+ * @param {number} options.initialCount — current row count in cache
18
+ * @param {Function} options.onRefresh — callback after cache refresh
19
+ * @param {string} [options.lang='ru'] — UI language
20
+ */
21
+ constructor({ provider, dimensions, maxCachedRows, initialCount, onRefresh, lang = 'ru' }) {
22
+ this._provider = provider;
23
+ this._dims = dimensions;
24
+ this._maxRows = maxCachedRows;
25
+ this._cached = new Set(provider.cachedDimensions);
26
+ this._count = initialCount;
27
+ this._stale = false;
28
+ this._checking = false;
29
+ this._toastTimer = null;
30
+ this._onRefresh = onRefresh;
31
+ this._t = (key, vars = {}) => {
32
+ let str = (I18N[lang] || I18N.ru)[key] || key;
33
+ for (const [k, v] of Object.entries(vars)) str = str.replace(`{${k}}`, v);
34
+ return str;
35
+ };
36
+
37
+ this._render();
38
+ this._bindRefresh();
39
+ }
40
+
41
+ // ── Render ────────────────────────────────────────────────────────────────
42
+
43
+ /** Renders all UI parts: chips, meter, status. */
44
+ _render() {
45
+ this._renderChips();
46
+ this._renderMeter();
47
+ this._renderStatus();
48
+ }
49
+
50
+ /** Renders dimension chips with cached/uncached state. */
51
+ _renderChips() {
52
+ const body = document.getElementById('cache-chips');
53
+ body.innerHTML = '';
54
+
55
+ for (const dim of this._dims) {
56
+ const chip = document.createElement('div');
57
+ chip.className = 'cache-chip' + (this._cached.has(dim) ? ' is-cached' : '');
58
+ chip.dataset.dim = dim;
59
+ const def = CONFIG.fields[dim] || {};
60
+ chip.textContent = def.title || def.label || dim;
61
+ chip.title = this._cached.has(dim)
62
+ ? this._t('cacheRemove')
63
+ : this._t('cacheAdd');
64
+ chip.addEventListener('click', () => this._toggle(dim));
65
+ body.appendChild(chip);
66
+ }
67
+ }
68
+
69
+ /** Returns the chip element for a given dimension. */
70
+ _chip(dim) {
71
+ return document.querySelector(`.cache-chip[data-dim="${dim}"]`);
72
+ }
73
+
74
+ /** Updates the fill meter bar and label. */
75
+ _renderMeter() {
76
+ const fill = document.getElementById('cache-meter-fill');
77
+ const label = document.getElementById('cache-meter-label');
78
+ const pct = this._cached.size > 0
79
+ ? Math.min(this._count / this._maxRows * 100, 100)
80
+ : 0;
81
+ const cls = pct < 60 ? 'ok' : pct < 85 ? 'warn' : 'danger';
82
+
83
+ fill.style.width = pct.toFixed(1) + '%';
84
+ fill.className = `cache-meter-fill ${cls}`;
85
+
86
+ if (this._cached.size === 0) {
87
+ label.textContent = '—';
88
+ label.className = 'cache-meter-label';
89
+ } else {
90
+ label.textContent = `~${this._count.toLocaleString()} / ${this._maxRows.toLocaleString()}`;
91
+ label.className = `cache-meter-label ${cls}`;
92
+ }
93
+ }
94
+
95
+ /** Updates the cache status label and refresh button state. */
96
+ _renderStatus() {
97
+ const status = document.getElementById('cache-status');
98
+ const btn = document.getElementById('btn-refresh-cache');
99
+
100
+ if (this._stale) {
101
+ status.textContent = this._t('cacheStale');
102
+ status.className = 'cache-status stale';
103
+ btn.disabled = false;
104
+ btn.classList.add('cache-refresh-btn--stale');
105
+ } else if (this._cached.size === 0) {
106
+ status.textContent = this._t('cacheEmpty');
107
+ status.className = 'cache-status empty';
108
+ btn.disabled = true;
109
+ btn.classList.remove('cache-refresh-btn--stale');
110
+ } else {
111
+ status.textContent = this._t('cacheActual');
112
+ status.className = 'cache-status fresh';
113
+ btn.disabled = true;
114
+ btn.classList.remove('cache-refresh-btn--stale');
115
+ }
116
+ }
117
+
118
+ // ── Toggle ────────────────────────────────────────────────────────────────
119
+
120
+ /**
121
+ * Toggles a dimension in/out of the cache set.
122
+ * When adding, runs a COUNT query to validate the row limit first.
123
+ * @param {string} dim — logical dimension name
124
+ */
125
+ async _toggle(dim) {
126
+ if (this._checking) return;
127
+
128
+ const chip = this._chip(dim);
129
+ if (!chip) return;
130
+
131
+ if (this._cached.has(dim)) {
132
+ this._cached.delete(dim);
133
+ chip.className = 'cache-chip';
134
+ chip.title = this._t('cacheAdd');
135
+ this._stale = true;
136
+ this._renderStatus();
137
+ this._refreshCountAsync();
138
+ return;
139
+ }
140
+
141
+ this._checking = true;
142
+ chip.className = 'cache-chip is-checking';
143
+
144
+ try {
145
+ const trial = [...this._cached, dim];
146
+ const count = await this._provider.countRows(trial);
147
+
148
+ if (count > this._maxRows) {
149
+ chip.className = 'cache-chip is-rejected';
150
+ this._showToast(
151
+ `«${dim}»: ~${count.toLocaleString()} — ${this._t('cacheExceeds')} ${this._maxRows.toLocaleString()}`
152
+ );
153
+ setTimeout(() => {
154
+ chip.className = 'cache-chip';
155
+ chip.title = this._t('cacheAdd');
156
+ }, 700);
157
+ } else {
158
+ this._cached.add(dim);
159
+ this._count = count;
160
+ this._stale = true;
161
+ chip.className = 'cache-chip is-cached';
162
+ chip.title = this._t('cacheRemove');
163
+ }
164
+ } catch (err) {
165
+ chip.className = 'cache-chip';
166
+ this._showToast(this._t('errorPrefix') + (err.message || err));
167
+ } finally {
168
+ this._checking = false;
169
+ this._renderMeter();
170
+ this._renderStatus();
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Refreshes the row count for the current cached dimensions asynchronously.
176
+ * Used after removing a dimension from cache.
177
+ */
178
+ async _refreshCountAsync() {
179
+ if (this._cached.size === 0) {
180
+ this._count = 0;
181
+ this._renderMeter();
182
+ return;
183
+ }
184
+ try {
185
+ this._count = await this._provider.countRows([...this._cached]);
186
+ } catch {
187
+ this._count = 0;
188
+ }
189
+ this._renderMeter();
190
+ }
191
+
192
+ // ── Refresh ───────────────────────────────────────────────────────────────
193
+
194
+ /** Binds the "Refresh cache" button click handler. */
195
+ _bindRefresh() {
196
+ const btn = document.getElementById('btn-refresh-cache');
197
+ const zone = document.querySelector('.cache-zone');
198
+
199
+ btn.addEventListener('click', async () => {
200
+ btn.disabled = true;
201
+ zone.classList.add('cache-zone--loading');
202
+ this._showFullscreenLoader(true);
203
+
204
+ try {
205
+ await this._provider.refreshCache([...this._cached]);
206
+ this._count = this._provider.cacheRows;
207
+ this._stale = false;
208
+ this._renderChips();
209
+ this._renderMeter();
210
+ this._renderStatus();
211
+ await this._onRefresh?.();
212
+ } catch (err) {
213
+ this._showToast(this._t('cacheRefreshError') + (err.message || err));
214
+ } finally {
215
+ this._showFullscreenLoader(false);
216
+ zone.classList.remove('cache-zone--loading');
217
+ this._renderStatus();
218
+ }
219
+ });
220
+ }
221
+
222
+ // ── Toast / Loader ────────────────────────────────────────────────────────
223
+
224
+ /**
225
+ * Shows or hides the fullscreen loading overlay.
226
+ * @param {boolean} on
227
+ */
228
+ _showFullscreenLoader(on) {
229
+ let el = document.getElementById('cache-fullscreen-loader');
230
+ if (on) {
231
+ if (!el) {
232
+ el = document.createElement('div');
233
+ el.id = 'cache-fullscreen-loader';
234
+ el.textContent = this._t('loading');
235
+ document.body.appendChild(el);
236
+ }
237
+ } else {
238
+ el?.remove();
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Shows a brief toast notification at the bottom of the screen.
244
+ * @param {string} msg — message to display
245
+ */
246
+ _showToast(msg) {
247
+ const toast = document.getElementById('cache-toast');
248
+ toast.textContent = msg;
249
+ toast.classList.add('visible');
250
+ clearTimeout(this._toastTimer);
251
+ this._toastTimer = setTimeout(() => toast.classList.remove('visible'), 3500);
252
+ }
253
+ }
package/widget/i18n.js ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * i18n.js — переводы интерфейса PivotGrid
3
+ *
4
+ * Язык задаётся через data-lang на контейнере:
5
+ * <div data-config="sales" data-lang="en"></div>
6
+ *
7
+ * По умолчанию: ru
8
+ * Добавить язык: добавить новый ключ в I18N.
9
+ */
10
+ const I18N = {
11
+ ru: {
12
+ loading: 'Загрузка данных...',
13
+ loadingGrid: 'Загрузка данных…',
14
+ cache: 'Кэш',
15
+ constructor: 'Конструктор',
16
+ filters: 'Фильтры',
17
+ fields: 'Поля',
18
+ rows: 'Строки',
19
+ columns: 'Колонки',
20
+ measure: 'Мера',
21
+ func: 'Функция',
22
+ expandRows: 'Развернуть строки',
23
+ collapseRows: 'Свернуть строки',
24
+ expandCols: 'Развернуть колонки',
25
+ collapseCols: 'Свернуть колонки',
26
+ subtotals: '∑ Подитоги',
27
+ exportCsv: '↓ CSV',
28
+ drillthrough: 'Drillthrough',
29
+ allData: 'Все данные',
30
+ noData: 'Нет данных',
31
+ shown: 'Показано',
32
+ firstN: 'Первые {n} записей',
33
+ total: 'Итого',
34
+ cacheEmpty: 'Кэш пуст',
35
+ cacheActual: 'Актуален',
36
+ cacheStale: 'Изменён · нужно обновить',
37
+ cacheRefresh: '↺ Обновить кэш',
38
+ errorPrefix: 'Ошибка: ',
39
+ cacheAdd: 'Добавить в кэш',
40
+ cacheRemove: 'Убрать из кэша',
41
+ cacheExceeds: 'строк — превышает лимит',
42
+ cacheRefreshError: 'Ошибка обновления кэша: ',
43
+ title: 'Сводная таблица',
44
+ // config-editor
45
+ ce_server: 'Сервер',
46
+ ce_database: 'База данных',
47
+ ce_query: 'Запрос',
48
+ ce_mainQuery: 'Основной query',
49
+ ce_colsQuery: 'Запрос для получения колонок',
50
+ ce_funcs: 'Функции агрегации',
51
+ ce_fetchCols: 'Получить колонки',
52
+ ce_drillthrough: 'Drillthrough',
53
+ ce_cols: 'Колонки',
54
+ ce_colDb: 'Колонка БД',
55
+ ce_colTitle: 'Название (title)',
56
+ ce_initialState: 'Начальное состояние',
57
+ ce_maxCacheRows: 'Макс. строк кэша',
58
+ ce_filterLimit: 'Лимит чекбоксов',
59
+ ce_configJs: 'config.js',
60
+ ce_save: 'Сохранить',
61
+ ce_loadFromServer: 'Загрузить с сервера',
62
+ ce_preview: '▶ Предпросмотр',
63
+ ce_saveConfig: '↑ Сохранить',
64
+ ce_selectConfig: '— выбрать конфиг —',
65
+ ce_configName: 'Имя конфига',
66
+ ce_dimension: 'Измерение',
67
+ ce_measure_type: 'Мера',
68
+ ce_fillUrl: 'Заполните URL и запрос',
69
+ ce_zeroRows: 'Запрос вернул 0 строк',
70
+ ce_colsLoaded: 'Получено {n} колонок',
71
+ ce_loadError: 'Ошибка: ',
72
+ ce_fetchBtn: 'Получить колонки',
73
+ ce_emptyFields: 'Нажмите «Получить колонки»',
74
+ ce_fillDbFields: 'Заполните host, database и user',
75
+ ce_dbSaved: 'Настройки сохранены',
76
+ ce_dbLoaded: 'Настройки загружены (пароль не передаётся)',
77
+ ce_enterName: 'Введите имя конфига',
78
+ ce_emptyConfig: 'Конфиг пуст',
79
+ ce_configSaved: 'Конфиг «{name}» сохранён',
80
+ ce_loadFailed: 'Ошибка загрузки: ',
81
+ ce_saveFailed: 'Ошибка сохранения: ',
82
+ ce_connector: 'Коннектор',
83
+ ce_dtSqlLabel: 'SELECT запрос (используй {filters})',
84
+ ce_dtUrlLabel: 'Base URL (фильтры добавятся как ?region=Север)',
85
+ ce_testConnection: 'Проверить соединение',
86
+ ce_testOk: 'Соединение успешно',
87
+ ce_testError: 'Ошибка соединения: ',
88
+ confirmLargeExpand: 'Слишком много строк для отображения.\n\nПосле разворачивания грид будет содержать ~{millions} млн строк, что превышает возможности браузера. Часть данных в нижней части будет недоступна.\n\nРекомендуем свернуть часть измерений.\n\nНажмите ОК чтобы всё равно развернуть, Отмена чтобы отказаться.',
89
+ ce_newConfig: 'Новый',
90
+ ce_deleteConfig: 'Удалить',
91
+ ce_confirmDelete: 'Удалить конфиг «{name}»?',
92
+ ce_deleteOk: 'Конфиг «{name}» удалён',
93
+ ce_deleteFailed: 'Ошибка удаления: ',
94
+ },
95
+ en: {
96
+ loading: 'Loading...',
97
+ loadingGrid: 'Loading data…',
98
+ cache: 'Cache',
99
+ constructor: 'Constructor',
100
+ filters: 'Filters',
101
+ fields: 'Fields',
102
+ rows: 'Rows',
103
+ columns: 'Columns',
104
+ measure: 'Measure',
105
+ func: 'Function',
106
+ expandRows: 'Expand rows',
107
+ collapseRows: 'Collapse rows',
108
+ expandCols: 'Expand columns',
109
+ collapseCols: 'Collapse columns',
110
+ subtotals: '∑ Subtotals',
111
+ exportCsv: '↓ CSV',
112
+ drillthrough: 'Drillthrough',
113
+ allData: 'All data',
114
+ noData: 'No data',
115
+ shown: 'Shown',
116
+ firstN: 'First {n} records',
117
+ total: 'Total',
118
+ cacheEmpty: 'Cache empty',
119
+ cacheActual: 'Up to date',
120
+ cacheStale: 'Changed · refresh needed',
121
+ cacheRefresh: '↺ Refresh cache',
122
+ errorPrefix: 'Error: ',
123
+ cacheAdd: 'Add to cache',
124
+ cacheRemove: 'Remove from cache',
125
+ cacheExceeds: 'rows — exceeds limit',
126
+ cacheRefreshError: 'Cache refresh error: ',
127
+ title: 'Pivot Table',
128
+ // config-editor
129
+ ce_server: 'Server',
130
+ ce_database: 'Database',
131
+ ce_query: 'Query',
132
+ ce_mainQuery: 'Main query',
133
+ ce_colsQuery: 'Query to fetch columns',
134
+ ce_funcs: 'Aggregation functions',
135
+ ce_fetchCols: 'Fetch columns',
136
+ ce_drillthrough: 'Drillthrough',
137
+ ce_cols: 'Columns',
138
+ ce_colDb: 'DB column',
139
+ ce_colTitle: 'Title',
140
+ ce_initialState: 'Initial state',
141
+ ce_maxCacheRows: 'Max cache rows',
142
+ ce_filterLimit: 'Checkbox limit',
143
+ ce_configJs: 'config.js',
144
+ ce_save: 'Save',
145
+ ce_loadFromServer: 'Load from server',
146
+ ce_preview: '▶ Preview',
147
+ ce_saveConfig: '↑ Save',
148
+ ce_selectConfig: '— select config —',
149
+ ce_configName: 'Config name',
150
+ ce_dimension: 'Dimension',
151
+ ce_measure_type: 'Measure',
152
+ ce_fillUrl: 'Fill in URL and query',
153
+ ce_zeroRows: 'Query returned 0 rows',
154
+ ce_colsLoaded: '{n} columns fetched',
155
+ ce_loadError: 'Error: ',
156
+ ce_fetchBtn: 'Fetch columns',
157
+ ce_emptyFields: 'Click "Fetch columns"',
158
+ ce_fillDbFields: 'Fill in host, database and user',
159
+ ce_dbSaved: 'Settings saved',
160
+ ce_dbLoaded: 'Settings loaded (password not included)',
161
+ ce_enterName: 'Enter config name',
162
+ ce_emptyConfig: 'Config is empty',
163
+ ce_configSaved: 'Config "{name}" saved',
164
+ ce_loadFailed: 'Load error: ',
165
+ ce_saveFailed: 'Save error: ',
166
+ ce_connector: 'Connector',
167
+ ce_dtSqlLabel: 'SELECT query (use {filters})',
168
+ ce_dtUrlLabel: 'Base URL (filters will be appended as ?region=North)',
169
+ ce_testConnection: 'Test connection',
170
+ ce_testOk: 'Connection successful',
171
+ ce_testError: 'Connection error: ',
172
+ confirmLargeExpand: 'Too many rows to display.\n\nAfter expanding, the grid will contain ~{millions}M rows which exceeds browser limits. Some rows at the bottom may not be reachable.\n\nConsider collapsing some row dimensions.\n\nClick OK to expand anyway, or Cancel to abort.',
173
+ ce_newConfig: 'New',
174
+ ce_deleteConfig: 'Delete',
175
+ ce_confirmDelete: 'Delete config "{name}"?',
176
+ ce_deleteOk: 'Config "{name}" deleted',
177
+ ce_deleteFailed: 'Delete error: ',
178
+ },
179
+ };