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