jupyterlab_tabular_data_viewer_extension 1.1.20
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.
- package/LICENSE +29 -0
- package/README.md +145 -0
- package/lib/document.d.ts +8 -0
- package/lib/document.js +9 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +214 -0
- package/lib/request.d.ts +8 -0
- package/lib/request.js +35 -0
- package/lib/widget.d.ts +127 -0
- package/lib/widget.js +644 -0
- package/package.json +217 -0
- package/schema/plugin.json +32 -0
- package/src/__tests__/jupyterlab_tabular_data_viewer_extension.spec.ts +9 -0
- package/src/document.ts +11 -0
- package/src/index.ts +276 -0
- package/src/request.ts +46 -0
- package/src/widget.ts +787 -0
- package/style/base.css +269 -0
- package/style/index.css +1 -0
- package/style/index.js +1 -0
package/src/widget.ts
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { Widget } from '@lumino/widgets';
|
|
2
|
+
import { requestAPI } from './request';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Column metadata interface
|
|
6
|
+
*/
|
|
7
|
+
interface IColumnMetadata {
|
|
8
|
+
name: string;
|
|
9
|
+
type: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Filter specification interface
|
|
14
|
+
*/
|
|
15
|
+
interface IFilterSpec {
|
|
16
|
+
type: 'text' | 'number';
|
|
17
|
+
value: string;
|
|
18
|
+
operator?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parquet viewer widget
|
|
23
|
+
*/
|
|
24
|
+
export class TabularDataViewer extends Widget {
|
|
25
|
+
private _filePath: string;
|
|
26
|
+
private _columns: IColumnMetadata[] = [];
|
|
27
|
+
private _data: any[] = [];
|
|
28
|
+
private _totalRows = 0;
|
|
29
|
+
private _unfilteredTotalRows = 0;
|
|
30
|
+
private _currentOffset = 0;
|
|
31
|
+
private _limit = 500;
|
|
32
|
+
private _loading = false;
|
|
33
|
+
private _hasMore = true;
|
|
34
|
+
private _filters: { [key: string]: IFilterSpec } = {};
|
|
35
|
+
private _sortBy: string | null = null;
|
|
36
|
+
private _sortOrder: 'asc' | 'desc' = 'asc';
|
|
37
|
+
private _fileSize = 0;
|
|
38
|
+
private _caseInsensitive = false;
|
|
39
|
+
private _useRegex = false;
|
|
40
|
+
private _contextMenuOpen = false;
|
|
41
|
+
private _columnWidths: Map<string, number> = new Map();
|
|
42
|
+
private _resizing: { columnName: string; startX: number; startWidth: number } | null = null;
|
|
43
|
+
|
|
44
|
+
private _tableContainer: HTMLDivElement;
|
|
45
|
+
private _table: HTMLTableElement;
|
|
46
|
+
private _thead: HTMLTableSectionElement;
|
|
47
|
+
private _tbody: HTMLTableSectionElement;
|
|
48
|
+
private _filterRow: HTMLTableRowElement;
|
|
49
|
+
private _headerRow: HTMLTableRowElement;
|
|
50
|
+
private _statusBar: HTMLDivElement;
|
|
51
|
+
private _statusLeft: HTMLDivElement;
|
|
52
|
+
private _statusRight: HTMLDivElement;
|
|
53
|
+
private _caseInsensitiveCheckbox: HTMLInputElement;
|
|
54
|
+
private _regexCheckbox: HTMLInputElement;
|
|
55
|
+
private _setLastContextMenuRow: (row: any) => void;
|
|
56
|
+
private _cleanupHighlight: (() => void) | null = null;
|
|
57
|
+
private _menuObserver: MutationObserver | null = null;
|
|
58
|
+
|
|
59
|
+
constructor(filePath: string, setLastContextMenuRow: (row: any) => void) {
|
|
60
|
+
super();
|
|
61
|
+
this._filePath = filePath;
|
|
62
|
+
this._setLastContextMenuRow = setLastContextMenuRow;
|
|
63
|
+
this.addClass('jp-TabularDataViewer');
|
|
64
|
+
|
|
65
|
+
// Create table container (scrollable)
|
|
66
|
+
this._tableContainer = document.createElement('div');
|
|
67
|
+
this._tableContainer.className = 'jp-TabularDataViewer-container';
|
|
68
|
+
|
|
69
|
+
// Create table
|
|
70
|
+
this._table = document.createElement('table');
|
|
71
|
+
this._table.className = 'jp-TabularDataViewer-table';
|
|
72
|
+
|
|
73
|
+
this._thead = document.createElement('thead');
|
|
74
|
+
this._thead.className = 'jp-TabularDataViewer-thead';
|
|
75
|
+
|
|
76
|
+
this._tbody = document.createElement('tbody');
|
|
77
|
+
this._tbody.className = 'jp-TabularDataViewer-tbody';
|
|
78
|
+
|
|
79
|
+
// Create filter row
|
|
80
|
+
this._filterRow = document.createElement('tr');
|
|
81
|
+
this._filterRow.className = 'jp-TabularDataViewer-filterRow';
|
|
82
|
+
|
|
83
|
+
// Create header row
|
|
84
|
+
this._headerRow = document.createElement('tr');
|
|
85
|
+
this._headerRow.className = 'jp-TabularDataViewer-headerRow';
|
|
86
|
+
|
|
87
|
+
this._thead.appendChild(this._filterRow);
|
|
88
|
+
this._thead.appendChild(this._headerRow);
|
|
89
|
+
|
|
90
|
+
this._table.appendChild(this._thead);
|
|
91
|
+
this._table.appendChild(this._tbody);
|
|
92
|
+
this._tableContainer.appendChild(this._table);
|
|
93
|
+
|
|
94
|
+
// Create status bar (outside scroll container)
|
|
95
|
+
this._statusBar = document.createElement('div');
|
|
96
|
+
this._statusBar.className = 'jp-TabularDataViewer-statusBar';
|
|
97
|
+
|
|
98
|
+
this._statusLeft = document.createElement('div');
|
|
99
|
+
this._statusLeft.className = 'jp-TabularDataViewer-statusLeft';
|
|
100
|
+
|
|
101
|
+
// Create middle section with case-insensitive checkbox
|
|
102
|
+
const statusMiddle = document.createElement('div');
|
|
103
|
+
statusMiddle.className = 'jp-TabularDataViewer-statusMiddle';
|
|
104
|
+
|
|
105
|
+
const checkboxLabel = document.createElement('label');
|
|
106
|
+
checkboxLabel.className = 'jp-TabularDataViewer-caseInsensitiveLabel';
|
|
107
|
+
|
|
108
|
+
this._caseInsensitiveCheckbox = document.createElement('input');
|
|
109
|
+
this._caseInsensitiveCheckbox.type = 'checkbox';
|
|
110
|
+
this._caseInsensitiveCheckbox.className = 'jp-TabularDataViewer-caseInsensitiveCheckbox';
|
|
111
|
+
this._caseInsensitiveCheckbox.checked = this._caseInsensitive;
|
|
112
|
+
this._caseInsensitiveCheckbox.addEventListener('change', () => {
|
|
113
|
+
this._caseInsensitive = this._caseInsensitiveCheckbox.checked;
|
|
114
|
+
this._loadData(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const checkboxText = document.createElement('span');
|
|
118
|
+
checkboxText.textContent = ' Case insensitive';
|
|
119
|
+
|
|
120
|
+
checkboxLabel.appendChild(this._caseInsensitiveCheckbox);
|
|
121
|
+
checkboxLabel.appendChild(checkboxText);
|
|
122
|
+
statusMiddle.appendChild(checkboxLabel);
|
|
123
|
+
|
|
124
|
+
// Create regex checkbox
|
|
125
|
+
const regexLabel = document.createElement('label');
|
|
126
|
+
regexLabel.className = 'jp-TabularDataViewer-regexLabel';
|
|
127
|
+
|
|
128
|
+
this._regexCheckbox = document.createElement('input');
|
|
129
|
+
this._regexCheckbox.type = 'checkbox';
|
|
130
|
+
this._regexCheckbox.className = 'jp-TabularDataViewer-regexCheckbox';
|
|
131
|
+
this._regexCheckbox.checked = this._useRegex;
|
|
132
|
+
this._regexCheckbox.addEventListener('change', () => {
|
|
133
|
+
this._useRegex = this._regexCheckbox.checked;
|
|
134
|
+
this._loadData(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const regexText = document.createElement('span');
|
|
138
|
+
regexText.textContent = ' Use regex';
|
|
139
|
+
|
|
140
|
+
regexLabel.appendChild(this._regexCheckbox);
|
|
141
|
+
regexLabel.appendChild(regexText);
|
|
142
|
+
statusMiddle.appendChild(regexLabel);
|
|
143
|
+
|
|
144
|
+
this._statusRight = document.createElement('div');
|
|
145
|
+
this._statusRight.className = 'jp-TabularDataViewer-statusRight';
|
|
146
|
+
|
|
147
|
+
this._statusBar.appendChild(this._statusLeft);
|
|
148
|
+
this._statusBar.appendChild(statusMiddle);
|
|
149
|
+
this._statusBar.appendChild(this._statusRight);
|
|
150
|
+
|
|
151
|
+
// Append table container and status bar directly to widget node
|
|
152
|
+
this.node.appendChild(this._tableContainer);
|
|
153
|
+
this.node.appendChild(this._statusBar);
|
|
154
|
+
|
|
155
|
+
// Set up scroll listener for progressive loading
|
|
156
|
+
this._tableContainer.addEventListener('scroll', () => {
|
|
157
|
+
this._onScroll();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Remove context-active class when clicking anywhere or dismissing context menu
|
|
161
|
+
const removeHighlight = () => {
|
|
162
|
+
this._contextMenuOpen = false;
|
|
163
|
+
this._tbody.classList.remove('jp-TabularDataViewer-context-menu-open');
|
|
164
|
+
this._tbody.querySelectorAll('tr').forEach(r => {
|
|
165
|
+
r.classList.remove('jp-TabularDataViewer-row-context-active');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Stop observing when highlight is removed
|
|
169
|
+
if (this._menuObserver) {
|
|
170
|
+
this._menuObserver.disconnect();
|
|
171
|
+
this._menuObserver = null;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Store cleanup function for external access
|
|
176
|
+
this._cleanupHighlight = removeHighlight;
|
|
177
|
+
|
|
178
|
+
// Initialize
|
|
179
|
+
this._initialize();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Start observing for menu removal when context menu opens
|
|
184
|
+
*/
|
|
185
|
+
private _startMenuObserver(): void {
|
|
186
|
+
// Stop any existing observer
|
|
187
|
+
if (this._menuObserver) {
|
|
188
|
+
this._menuObserver.disconnect();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Create observer to watch for menu removal from DOM
|
|
192
|
+
this._menuObserver = new MutationObserver((mutations) => {
|
|
193
|
+
for (const mutation of mutations) {
|
|
194
|
+
if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
|
|
195
|
+
// Check if any removed node is a Lumino menu
|
|
196
|
+
for (const node of Array.from(mutation.removedNodes)) {
|
|
197
|
+
if (node instanceof HTMLElement &&
|
|
198
|
+
(node.classList.contains('lm-Menu') || node.classList.contains('p-Menu'))) {
|
|
199
|
+
// Menu was removed, clear highlight
|
|
200
|
+
if (this._cleanupHighlight) {
|
|
201
|
+
this._cleanupHighlight();
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Observe document body for child removals
|
|
211
|
+
this._menuObserver.observe(document.body, {
|
|
212
|
+
childList: true,
|
|
213
|
+
subtree: false
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Get cleanup function to clear highlight (for external use)
|
|
219
|
+
*/
|
|
220
|
+
public getCleanupHighlight(): () => void {
|
|
221
|
+
return this._cleanupHighlight || (() => {});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Initialize the viewer by loading metadata and initial data
|
|
226
|
+
*/
|
|
227
|
+
private async _initialize(): Promise<void> {
|
|
228
|
+
try {
|
|
229
|
+
await this._loadMetadata();
|
|
230
|
+
await this._loadData(true);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
this._showError(`Failed to load file: ${error}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Load file metadata (columns, types, row count)
|
|
238
|
+
*/
|
|
239
|
+
private async _loadMetadata(): Promise<void> {
|
|
240
|
+
const response = await requestAPI<any>('metadata', {
|
|
241
|
+
method: 'POST',
|
|
242
|
+
body: JSON.stringify({ path: this._filePath })
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
this._columns = response.columns;
|
|
246
|
+
this._totalRows = response.totalRows;
|
|
247
|
+
this._unfilteredTotalRows = response.totalRows;
|
|
248
|
+
this._fileSize = response.fileSize || 0;
|
|
249
|
+
|
|
250
|
+
this._renderHeaders();
|
|
251
|
+
this._updateStatusBar();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Load data from server
|
|
256
|
+
*/
|
|
257
|
+
private async _loadData(reset = false): Promise<void> {
|
|
258
|
+
if (this._loading) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this._loading = true;
|
|
263
|
+
this._updateStatusBar('Loading...');
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
if (reset) {
|
|
267
|
+
this._currentOffset = 0;
|
|
268
|
+
this._data = [];
|
|
269
|
+
this._tbody.innerHTML = '';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const response = await requestAPI<any>('data', {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
path: this._filePath,
|
|
276
|
+
offset: this._currentOffset,
|
|
277
|
+
limit: this._limit,
|
|
278
|
+
filters: this._filters,
|
|
279
|
+
sortBy: this._sortBy,
|
|
280
|
+
sortOrder: this._sortOrder,
|
|
281
|
+
caseInsensitive: this._caseInsensitive,
|
|
282
|
+
useRegex: this._useRegex
|
|
283
|
+
})
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
this._data = this._data.concat(response.data);
|
|
287
|
+
this._hasMore = response.hasMore;
|
|
288
|
+
this._currentOffset += response.data.length;
|
|
289
|
+
|
|
290
|
+
if (reset) {
|
|
291
|
+
this._totalRows = response.totalRows;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
this._renderData(response.data);
|
|
295
|
+
this._updateStatusBar();
|
|
296
|
+
} catch (error) {
|
|
297
|
+
this._showError(`Failed to load data: ${error}`);
|
|
298
|
+
} finally {
|
|
299
|
+
this._loading = false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Render table headers with filter inputs
|
|
305
|
+
*/
|
|
306
|
+
private _renderHeaders(): void {
|
|
307
|
+
this._filterRow.innerHTML = '';
|
|
308
|
+
this._headerRow.innerHTML = '';
|
|
309
|
+
|
|
310
|
+
this._columns.forEach(col => {
|
|
311
|
+
// Create filter cell
|
|
312
|
+
const filterCell = document.createElement('th');
|
|
313
|
+
filterCell.className = 'jp-TabularDataViewer-filterCell';
|
|
314
|
+
|
|
315
|
+
const filterInput = document.createElement('input');
|
|
316
|
+
filterInput.type = 'text';
|
|
317
|
+
filterInput.className = 'jp-TabularDataViewer-filterInput';
|
|
318
|
+
filterInput.placeholder = this._getFilterPlaceholder(col.type);
|
|
319
|
+
filterInput.dataset.columnName = col.name;
|
|
320
|
+
filterInput.dataset.columnType = col.type;
|
|
321
|
+
|
|
322
|
+
filterInput.addEventListener('keyup', (e: KeyboardEvent) => {
|
|
323
|
+
if (e.key === 'Enter') {
|
|
324
|
+
this._applyFilter(col.name, filterInput.value, col.type);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
filterCell.appendChild(filterInput);
|
|
329
|
+
this._filterRow.appendChild(filterCell);
|
|
330
|
+
|
|
331
|
+
// Create header cell with column name and type
|
|
332
|
+
const headerCell = document.createElement('th');
|
|
333
|
+
headerCell.className = 'jp-TabularDataViewer-headerCell';
|
|
334
|
+
headerCell.style.cursor = 'pointer';
|
|
335
|
+
headerCell.dataset.columnName = col.name;
|
|
336
|
+
|
|
337
|
+
// Add click handler for sorting
|
|
338
|
+
headerCell.addEventListener('click', () => {
|
|
339
|
+
this._toggleSort(col.name);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const headerContent = document.createElement('div');
|
|
343
|
+
headerContent.className = 'jp-TabularDataViewer-headerContent';
|
|
344
|
+
|
|
345
|
+
const nameSpan = document.createElement('div');
|
|
346
|
+
nameSpan.className = 'jp-TabularDataViewer-columnName';
|
|
347
|
+
nameSpan.textContent = col.name;
|
|
348
|
+
|
|
349
|
+
const typeSpan = document.createElement('div');
|
|
350
|
+
typeSpan.className = 'jp-TabularDataViewer-columnType';
|
|
351
|
+
typeSpan.textContent = this._simplifyType(col.type);
|
|
352
|
+
|
|
353
|
+
const sortIndicator = document.createElement('span');
|
|
354
|
+
sortIndicator.className = 'jp-TabularDataViewer-sortIndicator';
|
|
355
|
+
sortIndicator.textContent = '';
|
|
356
|
+
|
|
357
|
+
headerContent.appendChild(nameSpan);
|
|
358
|
+
headerContent.appendChild(typeSpan);
|
|
359
|
+
headerCell.appendChild(headerContent);
|
|
360
|
+
headerCell.appendChild(sortIndicator);
|
|
361
|
+
|
|
362
|
+
// Add resize handle
|
|
363
|
+
const resizeHandle = document.createElement('div');
|
|
364
|
+
resizeHandle.className = 'jp-TabularDataViewer-resizeHandle';
|
|
365
|
+
resizeHandle.addEventListener('mousedown', (e: MouseEvent) => {
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
e.stopPropagation();
|
|
368
|
+
this._startResize(col.name, e.clientX, headerCell);
|
|
369
|
+
});
|
|
370
|
+
// Prevent click events from triggering sort
|
|
371
|
+
resizeHandle.addEventListener('click', (e: MouseEvent) => {
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
e.stopPropagation();
|
|
374
|
+
});
|
|
375
|
+
headerCell.appendChild(resizeHandle);
|
|
376
|
+
|
|
377
|
+
// Set width - use stored width or default to 200px
|
|
378
|
+
const columnWidth = this._columnWidths.get(col.name) || 200;
|
|
379
|
+
if (!this._columnWidths.has(col.name)) {
|
|
380
|
+
this._columnWidths.set(col.name, columnWidth);
|
|
381
|
+
}
|
|
382
|
+
headerCell.style.width = `${columnWidth}px`;
|
|
383
|
+
filterCell.style.width = `${columnWidth}px`;
|
|
384
|
+
|
|
385
|
+
this._headerRow.appendChild(headerCell);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Set table width to sum of all column widths
|
|
389
|
+
const totalWidth = Array.from(this._columnWidths.values()).reduce((sum, w) => sum + w, 0);
|
|
390
|
+
this._table.style.width = `${totalWidth}px`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Render data rows
|
|
395
|
+
*/
|
|
396
|
+
private _renderData(rows: any[]): void {
|
|
397
|
+
rows.forEach(row => {
|
|
398
|
+
const tr = document.createElement('tr');
|
|
399
|
+
tr.className = 'jp-TabularDataViewer-row';
|
|
400
|
+
|
|
401
|
+
this._columns.forEach(col => {
|
|
402
|
+
const td = document.createElement('td');
|
|
403
|
+
td.className = 'jp-TabularDataViewer-cell';
|
|
404
|
+
const value = row[col.name];
|
|
405
|
+
td.textContent = value !== null && value !== undefined ? String(value) : '';
|
|
406
|
+
tr.appendChild(td);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Remove context-active class when hovering over any row (only if context menu not open)
|
|
410
|
+
tr.addEventListener('mouseenter', () => {
|
|
411
|
+
// Don't clear highlight while context menu is open
|
|
412
|
+
if (!this._contextMenuOpen) {
|
|
413
|
+
this._tbody.querySelectorAll('tr').forEach(r => {
|
|
414
|
+
r.classList.remove('jp-TabularDataViewer-row-context-active');
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// Add right-click handler to store row data and maintain hover styling
|
|
420
|
+
tr.addEventListener('contextmenu', (e) => {
|
|
421
|
+
// Mark context menu as open
|
|
422
|
+
this._contextMenuOpen = true;
|
|
423
|
+
|
|
424
|
+
// Add class to tbody to disable hover on other rows
|
|
425
|
+
this._tbody.classList.add('jp-TabularDataViewer-context-menu-open');
|
|
426
|
+
|
|
427
|
+
// Remove context-active class from all rows
|
|
428
|
+
this._tbody.querySelectorAll('tr').forEach(r => {
|
|
429
|
+
r.classList.remove('jp-TabularDataViewer-row-context-active');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Add context-active class to keep hover styling visible
|
|
433
|
+
tr.classList.add('jp-TabularDataViewer-row-context-active');
|
|
434
|
+
|
|
435
|
+
// Store row data for context menu
|
|
436
|
+
this._setLastContextMenuRow(row);
|
|
437
|
+
|
|
438
|
+
// Start observing for menu removal
|
|
439
|
+
this._startMenuObserver();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
this._tbody.appendChild(tr);
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Start column resize
|
|
448
|
+
*/
|
|
449
|
+
private _startResize(columnName: string, startX: number, headerCell: HTMLElement): void {
|
|
450
|
+
this._resizing = {
|
|
451
|
+
columnName,
|
|
452
|
+
startX,
|
|
453
|
+
startWidth: headerCell.offsetWidth
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Add global mouse event listeners
|
|
457
|
+
document.addEventListener('mousemove', this._doResize);
|
|
458
|
+
document.addEventListener('mouseup', this._stopResize);
|
|
459
|
+
|
|
460
|
+
// Prevent text selection during resize
|
|
461
|
+
document.body.style.userSelect = 'none';
|
|
462
|
+
document.body.style.cursor = 'col-resize';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Handle column resize drag
|
|
467
|
+
*/
|
|
468
|
+
private _doResize = (e: MouseEvent): void => {
|
|
469
|
+
if (!this._resizing) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const deltaX = e.clientX - this._resizing.startX;
|
|
474
|
+
const newWidth = Math.max(80, this._resizing.startWidth + deltaX); // Minimum width of 80px
|
|
475
|
+
|
|
476
|
+
// Store the new width
|
|
477
|
+
this._columnWidths.set(this._resizing.columnName, newWidth);
|
|
478
|
+
|
|
479
|
+
// Apply the new width to the column header and filter cell only
|
|
480
|
+
// With table-layout: fixed, this automatically applies to all cells in the column
|
|
481
|
+
const columnIndex = this._columns.findIndex(col => col.name === this._resizing!.columnName);
|
|
482
|
+
if (columnIndex !== -1) {
|
|
483
|
+
const headerCell = this._headerRow.children[columnIndex] as HTMLElement;
|
|
484
|
+
const filterCell = this._filterRow.children[columnIndex] as HTMLElement;
|
|
485
|
+
|
|
486
|
+
if (headerCell) {
|
|
487
|
+
headerCell.style.width = `${newWidth}px`;
|
|
488
|
+
}
|
|
489
|
+
if (filterCell) {
|
|
490
|
+
filterCell.style.width = `${newWidth}px`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Update table width to sum of all column widths
|
|
494
|
+
const totalWidth = Array.from(this._columnWidths.values()).reduce((sum, w) => sum + w, 0);
|
|
495
|
+
this._table.style.width = `${totalWidth}px`;
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Stop column resize
|
|
501
|
+
*/
|
|
502
|
+
private _stopResize = (): void => {
|
|
503
|
+
if (!this._resizing) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
this._resizing = null;
|
|
508
|
+
|
|
509
|
+
// Remove global mouse event listeners
|
|
510
|
+
document.removeEventListener('mousemove', this._doResize);
|
|
511
|
+
document.removeEventListener('mouseup', this._stopResize);
|
|
512
|
+
|
|
513
|
+
// Restore user selection and cursor
|
|
514
|
+
document.body.style.userSelect = '';
|
|
515
|
+
document.body.style.cursor = '';
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Apply filter to a column
|
|
520
|
+
*/
|
|
521
|
+
private _applyFilter(columnName: string, value: string, columnType: string): void {
|
|
522
|
+
if (!value.trim()) {
|
|
523
|
+
// Remove filter if empty
|
|
524
|
+
delete this._filters[columnName];
|
|
525
|
+
} else {
|
|
526
|
+
const isNumeric = this._isNumericType(columnType);
|
|
527
|
+
|
|
528
|
+
if (isNumeric) {
|
|
529
|
+
// Parse numerical filter with operator
|
|
530
|
+
const match = value.match(/^([><=]+)?\s*(.+)$/);
|
|
531
|
+
if (match) {
|
|
532
|
+
const operator = match[1] || '=';
|
|
533
|
+
const numValue = match[2].trim();
|
|
534
|
+
|
|
535
|
+
this._filters[columnName] = {
|
|
536
|
+
type: 'number',
|
|
537
|
+
value: numValue,
|
|
538
|
+
operator: operator
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
// Text filter
|
|
543
|
+
this._filters[columnName] = {
|
|
544
|
+
type: 'text',
|
|
545
|
+
value: value
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Reload data with filters
|
|
551
|
+
this._loadData(true);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Simplify type names for display
|
|
556
|
+
*/
|
|
557
|
+
private _simplifyType(type: string): string {
|
|
558
|
+
const lowerType = type.toLowerCase();
|
|
559
|
+
|
|
560
|
+
// Date types
|
|
561
|
+
if (lowerType.includes('date32') || lowerType.includes('date64')) {
|
|
562
|
+
return 'date';
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Timestamp/datetime types
|
|
566
|
+
if (lowerType.includes('timestamp')) {
|
|
567
|
+
return 'datetime';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Integer types
|
|
571
|
+
if (lowerType.match(/^u?int(8|16|32|64)$/)) {
|
|
572
|
+
return 'int';
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Float types
|
|
576
|
+
if (lowerType.match(/^(float|double)(16|32|64)?$/)) {
|
|
577
|
+
return 'float';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Decimal types
|
|
581
|
+
if (lowerType.includes('decimal')) {
|
|
582
|
+
return 'decimal';
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Boolean
|
|
586
|
+
if (lowerType === 'bool') {
|
|
587
|
+
return 'boolean';
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// String types
|
|
591
|
+
if (lowerType === 'string' || lowerType === 'utf8' || lowerType === 'large_string' || lowerType === 'large_utf8') {
|
|
592
|
+
return 'string';
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Binary types
|
|
596
|
+
if (lowerType === 'binary' || lowerType === 'large_binary') {
|
|
597
|
+
return 'binary';
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// List types
|
|
601
|
+
if (lowerType.startsWith('list')) {
|
|
602
|
+
return 'list';
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Struct types
|
|
606
|
+
if (lowerType.startsWith('struct')) {
|
|
607
|
+
return 'struct';
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Default: return as-is
|
|
611
|
+
return type;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Check if column type is numeric
|
|
616
|
+
*/
|
|
617
|
+
private _isNumericType(type: string): boolean {
|
|
618
|
+
const numericTypes = ['int', 'float', 'double', 'decimal', 'int8', 'int16', 'int32', 'int64', 'uint8', 'uint16', 'uint32', 'uint64'];
|
|
619
|
+
return numericTypes.some(t => type.toLowerCase().includes(t));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Get filter placeholder based on column type
|
|
624
|
+
*/
|
|
625
|
+
private _getFilterPlaceholder(type: string): string {
|
|
626
|
+
if (this._isNumericType(type)) {
|
|
627
|
+
return '=, >, <, >=, <=';
|
|
628
|
+
}
|
|
629
|
+
return 'text or regex...';
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Handle scroll event for progressive loading
|
|
634
|
+
*/
|
|
635
|
+
private _onScroll(): void {
|
|
636
|
+
const container = this._tableContainer;
|
|
637
|
+
const scrollPosition = container.scrollTop + container.clientHeight;
|
|
638
|
+
const scrollHeight = container.scrollHeight;
|
|
639
|
+
|
|
640
|
+
// Load more when scrolled to within 200px of bottom
|
|
641
|
+
if (scrollPosition >= scrollHeight - 200 && this._hasMore && !this._loading) {
|
|
642
|
+
this._loadData(false);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Toggle sort on a column
|
|
648
|
+
*/
|
|
649
|
+
private _toggleSort(columnName: string): void {
|
|
650
|
+
if (this._sortBy === columnName) {
|
|
651
|
+
// Cycle through: asc -> desc -> off
|
|
652
|
+
if (this._sortOrder === 'asc') {
|
|
653
|
+
this._sortOrder = 'desc';
|
|
654
|
+
} else if (this._sortOrder === 'desc') {
|
|
655
|
+
// Turn off sorting
|
|
656
|
+
this._sortBy = null;
|
|
657
|
+
this._sortOrder = 'asc';
|
|
658
|
+
}
|
|
659
|
+
} else {
|
|
660
|
+
// Sort by new column (ascending)
|
|
661
|
+
this._sortBy = columnName;
|
|
662
|
+
this._sortOrder = 'asc';
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
this._updateSortIndicators();
|
|
666
|
+
this._loadData(true);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Update sort indicators in column headers
|
|
671
|
+
*/
|
|
672
|
+
private _updateSortIndicators(): void {
|
|
673
|
+
const headers = this._headerRow.querySelectorAll('th');
|
|
674
|
+
headers.forEach(header => {
|
|
675
|
+
const columnName = header.dataset.columnName;
|
|
676
|
+
const indicator = header.querySelector('.jp-TabularDataViewer-sortIndicator') as HTMLElement;
|
|
677
|
+
|
|
678
|
+
if (columnName === this._sortBy) {
|
|
679
|
+
indicator.textContent = this._sortOrder === 'asc' ? ' ▲' : ' ▼';
|
|
680
|
+
} else {
|
|
681
|
+
indicator.textContent = '';
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Clear all filters
|
|
688
|
+
*/
|
|
689
|
+
private _clearFilters(): void {
|
|
690
|
+
this._filters = {};
|
|
691
|
+
|
|
692
|
+
// Clear filter inputs
|
|
693
|
+
const filterInputs = this._filterRow.querySelectorAll('input');
|
|
694
|
+
filterInputs.forEach(input => {
|
|
695
|
+
(input as HTMLInputElement).value = '';
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
this._loadData(true);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Format file size to human-readable string
|
|
703
|
+
*/
|
|
704
|
+
private _formatFileSize(bytes: number): string {
|
|
705
|
+
if (bytes === 0) return '0 B';
|
|
706
|
+
const k = 1024;
|
|
707
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
708
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
709
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Update status bar
|
|
714
|
+
*/
|
|
715
|
+
private _updateStatusBar(message?: string): void {
|
|
716
|
+
if (message) {
|
|
717
|
+
this._statusLeft.textContent = '';
|
|
718
|
+
this._statusRight.textContent = message;
|
|
719
|
+
} else {
|
|
720
|
+
// Left side: file stats (always show unfiltered total)
|
|
721
|
+
const numColumns = this._columns.length;
|
|
722
|
+
const fileSize = this._formatFileSize(this._fileSize);
|
|
723
|
+
this._statusLeft.textContent = `${numColumns} columns • ${this._unfilteredTotalRows} rows • ${fileSize}`;
|
|
724
|
+
|
|
725
|
+
// Right side: showing info and clear filters link
|
|
726
|
+
const filterCount = Object.keys(this._filters).length;
|
|
727
|
+
let rightText = `Showing ${this._data.length} of ${this._totalRows} rows`;
|
|
728
|
+
|
|
729
|
+
if (filterCount > 0) {
|
|
730
|
+
rightText += ` (${filterCount} filter${filterCount > 1 ? 's' : ''} active)`;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
this._statusRight.innerHTML = rightText;
|
|
734
|
+
|
|
735
|
+
if (filterCount > 0) {
|
|
736
|
+
const clearLink = document.createElement('a');
|
|
737
|
+
clearLink.href = '#';
|
|
738
|
+
clearLink.className = 'jp-TabularDataViewer-clearFilters';
|
|
739
|
+
clearLink.textContent = 'Clear filters';
|
|
740
|
+
clearLink.addEventListener('click', (e) => {
|
|
741
|
+
e.preventDefault();
|
|
742
|
+
this._clearFilters();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
this._statusRight.appendChild(document.createTextNode(' • '));
|
|
746
|
+
this._statusRight.appendChild(clearLink);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Show error message
|
|
753
|
+
*/
|
|
754
|
+
private _showError(message: string): void {
|
|
755
|
+
this._tbody.innerHTML = '';
|
|
756
|
+
const tr = document.createElement('tr');
|
|
757
|
+
const td = document.createElement('td');
|
|
758
|
+
td.colSpan = this._columns.length || 1;
|
|
759
|
+
td.className = 'jp-TabularDataViewer-error';
|
|
760
|
+
td.textContent = message;
|
|
761
|
+
tr.appendChild(td);
|
|
762
|
+
this._tbody.appendChild(tr);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Dispose of the widget
|
|
767
|
+
*/
|
|
768
|
+
dispose(): void {
|
|
769
|
+
this._tableContainer.removeEventListener('scroll', this._onScroll);
|
|
770
|
+
|
|
771
|
+
// Clean up menu observer
|
|
772
|
+
if (this._menuObserver) {
|
|
773
|
+
this._menuObserver.disconnect();
|
|
774
|
+
this._menuObserver = null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Clean up resize event listeners if still active
|
|
778
|
+
if (this._resizing) {
|
|
779
|
+
document.removeEventListener('mousemove', this._doResize);
|
|
780
|
+
document.removeEventListener('mouseup', this._stopResize);
|
|
781
|
+
document.body.style.userSelect = '';
|
|
782
|
+
document.body.style.cursor = '';
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
super.dispose();
|
|
786
|
+
}
|
|
787
|
+
}
|