jupyterlab_tabular_data_viewer_extension 1.1.20 → 1.2.8
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/README.md +14 -2
- package/lib/index.js +1 -3
- package/lib/modal.d.ts +33 -0
- package/lib/modal.js +201 -0
- package/lib/request.d.ts +39 -0
- package/lib/request.js +16 -0
- package/lib/widget.d.ts +4 -0
- package/lib/widget.js +29 -1
- package/package.json +1 -1
- package/src/index.ts +3 -3
- package/src/modal.ts +229 -0
- package/src/request.ts +55 -0
- package/src/widget.ts +30 -1
- package/style/base.css +153 -5
package/README.md
CHANGED
|
@@ -10,6 +10,10 @@ View and browse Parquet, Excel, CSV, and TSV files directly in JupyterLab. Doubl
|
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
13
|
+
**Opening files:** Right-click any supported file and select "Tabular Data Viewer" from the "Open With" menu, or simply double-click to open with the default viewer.
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
13
17
|
## Features
|
|
14
18
|
|
|
15
19
|
**Supported File Formats:**
|
|
@@ -36,6 +40,12 @@ View and browse Parquet, Excel, CSV, and TSV files directly in JupyterLab. Doubl
|
|
|
36
40
|
- Multiple filters work together to narrow down results
|
|
37
41
|
|
|
38
42
|
**Additional features:**
|
|
43
|
+
- Column statistics modal - Hover over any column header to reveal an info icon, click it to view comprehensive statistics including data type, row counts, null values, unique counts, and type-specific metrics (numeric: min/max/mean/median/std dev/outliers using IQR×1.5; string: most common value/length stats; date: earliest/latest dates). Copy statistics as JSON with one click
|
|
44
|
+
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+

|
|
48
|
+
|
|
39
49
|
- Right-click context menu on rows to copy data as JSON
|
|
40
50
|
- Configurable file type support via Settings - Enable/disable Parquet, Excel, or CSV/TSV handling
|
|
41
51
|
- All features work seamlessly across all supported file formats
|
|
@@ -58,10 +68,12 @@ pip uninstall jupyterlab_tabular_data_viewer_extension
|
|
|
58
68
|
Configure file type support through JupyterLab Settings:
|
|
59
69
|
|
|
60
70
|
1. Open **Settings → Settings Editor**
|
|
61
|
-
2. Search for "
|
|
71
|
+
2. Search for "Tabular Data Viewer Extension"
|
|
62
72
|
3. Configure options:
|
|
63
73
|
- **Enable Parquet files** - Default: enabled
|
|
64
|
-
- **Enable Excel files** - Default:
|
|
74
|
+
- **Enable Excel files** - Default: enabled
|
|
75
|
+
- **Enable CSV files** - Default: enabled
|
|
76
|
+
- **Enable TSV files** - Default: enabled
|
|
65
77
|
|
|
66
78
|
When a file type is disabled, files open with JupyterLab's default handler instead.
|
|
67
79
|
|
package/lib/index.js
CHANGED
|
@@ -34,9 +34,7 @@ const plugin = {
|
|
|
34
34
|
autoStart: true,
|
|
35
35
|
requires: [ISettingRegistry],
|
|
36
36
|
activate: async (app, settingRegistry) => {
|
|
37
|
-
|
|
38
|
-
// 'JupyterLab extension jupyterlab_tabular_data_viewer_extension is activated!'
|
|
39
|
-
// );
|
|
37
|
+
console.log('JupyterLab extension jupyterlab_tabular_data_viewer_extension is activated!');
|
|
40
38
|
const { docRegistry, commands, contextMenu } = app;
|
|
41
39
|
// Track last right-clicked row for context menu
|
|
42
40
|
let lastContextMenuRow = null;
|
package/lib/modal.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Widget } from '@lumino/widgets';
|
|
2
|
+
import { IColumnStats } from './request';
|
|
3
|
+
/**
|
|
4
|
+
* Modal dialog widget for displaying column statistics
|
|
5
|
+
*/
|
|
6
|
+
export declare class ColumnStatsModal extends Widget {
|
|
7
|
+
private _stats;
|
|
8
|
+
constructor(stats: IColumnStats);
|
|
9
|
+
/**
|
|
10
|
+
* Render the modal content
|
|
11
|
+
*/
|
|
12
|
+
private _render;
|
|
13
|
+
/**
|
|
14
|
+
* Format number with thousands separator
|
|
15
|
+
*/
|
|
16
|
+
private _formatNumber;
|
|
17
|
+
/**
|
|
18
|
+
* Copy statistics as JSON to clipboard
|
|
19
|
+
*/
|
|
20
|
+
private _copyStatsAsJson;
|
|
21
|
+
/**
|
|
22
|
+
* Setup event listeners for closing the modal
|
|
23
|
+
*/
|
|
24
|
+
private _setupEventListeners;
|
|
25
|
+
/**
|
|
26
|
+
* Show the modal
|
|
27
|
+
*/
|
|
28
|
+
show(): void;
|
|
29
|
+
/**
|
|
30
|
+
* Close and dispose the modal
|
|
31
|
+
*/
|
|
32
|
+
close(): void;
|
|
33
|
+
}
|
package/lib/modal.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Widget } from '@lumino/widgets';
|
|
2
|
+
/**
|
|
3
|
+
* Modal dialog widget for displaying column statistics
|
|
4
|
+
*/
|
|
5
|
+
export class ColumnStatsModal extends Widget {
|
|
6
|
+
constructor(stats) {
|
|
7
|
+
super();
|
|
8
|
+
this._stats = stats;
|
|
9
|
+
this.addClass('jp-ColumnStatsModal');
|
|
10
|
+
this._render();
|
|
11
|
+
this._setupEventListeners();
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Render the modal content
|
|
15
|
+
*/
|
|
16
|
+
_render() {
|
|
17
|
+
const content = document.createElement('div');
|
|
18
|
+
content.className = 'jp-ColumnStatsModal-content';
|
|
19
|
+
// Header with column name and close button
|
|
20
|
+
const header = document.createElement('div');
|
|
21
|
+
header.className = 'jp-ColumnStatsModal-header';
|
|
22
|
+
const title = document.createElement('h3');
|
|
23
|
+
title.textContent = `Column: ${this._stats.column_name}`;
|
|
24
|
+
const closeBtn = document.createElement('button');
|
|
25
|
+
closeBtn.className = 'jp-ColumnStatsModal-close';
|
|
26
|
+
closeBtn.textContent = '×';
|
|
27
|
+
closeBtn.onclick = () => this.close();
|
|
28
|
+
header.appendChild(title);
|
|
29
|
+
header.appendChild(closeBtn);
|
|
30
|
+
content.appendChild(header);
|
|
31
|
+
// Copy JSON button
|
|
32
|
+
const copySection = document.createElement('div');
|
|
33
|
+
copySection.className = 'jp-ColumnStatsModal-copy';
|
|
34
|
+
const copyBtn = document.createElement('button');
|
|
35
|
+
copyBtn.className = 'jp-ColumnStatsModal-copyButton';
|
|
36
|
+
copyBtn.textContent = 'Copy Stats as JSON';
|
|
37
|
+
copyBtn.onclick = () => this._copyStatsAsJson();
|
|
38
|
+
copySection.appendChild(copyBtn);
|
|
39
|
+
content.appendChild(copySection);
|
|
40
|
+
// Data type
|
|
41
|
+
const typeDiv = document.createElement('div');
|
|
42
|
+
typeDiv.className = 'jp-ColumnStatsModal-type';
|
|
43
|
+
typeDiv.textContent = `Type: ${this._stats.data_type}`;
|
|
44
|
+
content.appendChild(typeDiv);
|
|
45
|
+
// Data Summary section
|
|
46
|
+
const summarySection = document.createElement('div');
|
|
47
|
+
summarySection.className = 'jp-ColumnStatsModal-section';
|
|
48
|
+
const summaryTitle = document.createElement('h4');
|
|
49
|
+
summaryTitle.textContent = 'Data Summary';
|
|
50
|
+
summarySection.appendChild(summaryTitle);
|
|
51
|
+
const summaryList = document.createElement('ul');
|
|
52
|
+
summaryList.innerHTML = `
|
|
53
|
+
<li>Total rows: ${this._formatNumber(this._stats.total_rows)}</li>
|
|
54
|
+
<li>Non-null: ${this._formatNumber(this._stats.non_null_count)} (${this._stats.non_null_percentage}%)</li>
|
|
55
|
+
<li>Null: ${this._formatNumber(this._stats.null_count)} (${this._stats.null_percentage}%)</li>
|
|
56
|
+
<li>Unique values: ${this._formatNumber(this._stats.unique_count)} (${this._stats.unique_percentage}%)</li>
|
|
57
|
+
`;
|
|
58
|
+
summarySection.appendChild(summaryList);
|
|
59
|
+
content.appendChild(summarySection);
|
|
60
|
+
// Numeric statistics
|
|
61
|
+
if (this._stats.data_type === 'int' || this._stats.data_type === 'float') {
|
|
62
|
+
const numericSection = document.createElement('div');
|
|
63
|
+
numericSection.className = 'jp-ColumnStatsModal-section';
|
|
64
|
+
const numericTitle = document.createElement('h4');
|
|
65
|
+
numericTitle.textContent = 'Numeric Statistics';
|
|
66
|
+
numericSection.appendChild(numericTitle);
|
|
67
|
+
const numericList = document.createElement('ul');
|
|
68
|
+
const items = [];
|
|
69
|
+
if (this._stats.min_value !== undefined) {
|
|
70
|
+
items.push(`Min: ${this._formatNumber(this._stats.min_value)}`);
|
|
71
|
+
}
|
|
72
|
+
if (this._stats.max_value !== undefined) {
|
|
73
|
+
items.push(`Max: ${this._formatNumber(this._stats.max_value)}`);
|
|
74
|
+
}
|
|
75
|
+
if (this._stats.mean !== undefined) {
|
|
76
|
+
items.push(`Mean: ${this._formatNumber(this._stats.mean)}`);
|
|
77
|
+
}
|
|
78
|
+
if (this._stats.median !== undefined) {
|
|
79
|
+
items.push(`Median: ${this._formatNumber(this._stats.median)}`);
|
|
80
|
+
}
|
|
81
|
+
if (this._stats.std_dev !== undefined) {
|
|
82
|
+
items.push(`Std Dev: ${this._formatNumber(this._stats.std_dev)}`);
|
|
83
|
+
}
|
|
84
|
+
if (this._stats.outlier_count !== undefined) {
|
|
85
|
+
items.push(`Outliers (IQR×1.5): ${this._formatNumber(this._stats.outlier_count)} (${this._stats.outlier_percentage}%)`);
|
|
86
|
+
}
|
|
87
|
+
numericList.innerHTML = items.map(item => `<li>${item}</li>`).join('');
|
|
88
|
+
numericSection.appendChild(numericList);
|
|
89
|
+
content.appendChild(numericSection);
|
|
90
|
+
}
|
|
91
|
+
// String statistics
|
|
92
|
+
if (this._stats.data_type === 'string') {
|
|
93
|
+
const stringSection = document.createElement('div');
|
|
94
|
+
stringSection.className = 'jp-ColumnStatsModal-section';
|
|
95
|
+
const stringTitle = document.createElement('h4');
|
|
96
|
+
stringTitle.textContent = 'String Statistics';
|
|
97
|
+
stringSection.appendChild(stringTitle);
|
|
98
|
+
const stringList = document.createElement('ul');
|
|
99
|
+
const items = [];
|
|
100
|
+
if (this._stats.most_common_value !== undefined) {
|
|
101
|
+
items.push(`Most common: "${this._stats.most_common_value}" (${this._stats.most_common_count})`);
|
|
102
|
+
}
|
|
103
|
+
if (this._stats.min_length !== undefined) {
|
|
104
|
+
items.push(`Min length: ${this._stats.min_length} characters`);
|
|
105
|
+
}
|
|
106
|
+
if (this._stats.max_length !== undefined) {
|
|
107
|
+
items.push(`Max length: ${this._stats.max_length} characters`);
|
|
108
|
+
}
|
|
109
|
+
if (this._stats.avg_length !== undefined) {
|
|
110
|
+
items.push(`Avg length: ${this._formatNumber(this._stats.avg_length)} characters`);
|
|
111
|
+
}
|
|
112
|
+
stringList.innerHTML = items.map(item => `<li>${item}</li>`).join('');
|
|
113
|
+
stringSection.appendChild(stringList);
|
|
114
|
+
content.appendChild(stringSection);
|
|
115
|
+
}
|
|
116
|
+
// Date/datetime statistics
|
|
117
|
+
if (this._stats.data_type === 'date' || this._stats.data_type === 'datetime') {
|
|
118
|
+
const dateSection = document.createElement('div');
|
|
119
|
+
dateSection.className = 'jp-ColumnStatsModal-section';
|
|
120
|
+
const dateTitle = document.createElement('h4');
|
|
121
|
+
dateTitle.textContent = 'Date Range';
|
|
122
|
+
dateSection.appendChild(dateTitle);
|
|
123
|
+
const dateList = document.createElement('ul');
|
|
124
|
+
const items = [];
|
|
125
|
+
if (this._stats.earliest_date) {
|
|
126
|
+
items.push(`Earliest: ${this._stats.earliest_date}`);
|
|
127
|
+
}
|
|
128
|
+
if (this._stats.latest_date) {
|
|
129
|
+
items.push(`Latest: ${this._stats.latest_date}`);
|
|
130
|
+
}
|
|
131
|
+
if (this._stats.date_range_days !== undefined) {
|
|
132
|
+
items.push(`Span: ${this._formatNumber(this._stats.date_range_days)} days`);
|
|
133
|
+
}
|
|
134
|
+
dateList.innerHTML = items.map(item => `<li>${item}</li>`).join('');
|
|
135
|
+
dateSection.appendChild(dateList);
|
|
136
|
+
content.appendChild(dateSection);
|
|
137
|
+
}
|
|
138
|
+
this.node.appendChild(content);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Format number with thousands separator
|
|
142
|
+
*/
|
|
143
|
+
_formatNumber(num) {
|
|
144
|
+
return num.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Copy statistics as JSON to clipboard
|
|
148
|
+
*/
|
|
149
|
+
async _copyStatsAsJson() {
|
|
150
|
+
try {
|
|
151
|
+
const json = JSON.stringify(this._stats, null, 2);
|
|
152
|
+
await navigator.clipboard.writeText(json);
|
|
153
|
+
// Provide visual feedback
|
|
154
|
+
const btn = this.node.querySelector('.jp-ColumnStatsModal-copyButton');
|
|
155
|
+
if (btn) {
|
|
156
|
+
const originalText = btn.textContent;
|
|
157
|
+
btn.textContent = 'Copied!';
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
btn.textContent = originalText;
|
|
160
|
+
}, 2000);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
console.error('Failed to copy stats to clipboard:', error);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Setup event listeners for closing the modal
|
|
169
|
+
*/
|
|
170
|
+
_setupEventListeners() {
|
|
171
|
+
// Close on ESC key
|
|
172
|
+
const handleKeydown = (event) => {
|
|
173
|
+
if (event.key === 'Escape') {
|
|
174
|
+
this.close();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
document.addEventListener('keydown', handleKeydown);
|
|
178
|
+
this.disposed.connect(() => {
|
|
179
|
+
document.removeEventListener('keydown', handleKeydown);
|
|
180
|
+
});
|
|
181
|
+
// Close on backdrop click
|
|
182
|
+
this.node.addEventListener('click', (event) => {
|
|
183
|
+
if (event.target === this.node) {
|
|
184
|
+
this.close();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Show the modal
|
|
190
|
+
*/
|
|
191
|
+
show() {
|
|
192
|
+
Widget.attach(this, document.body);
|
|
193
|
+
this.node.focus();
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Close and dispose the modal
|
|
197
|
+
*/
|
|
198
|
+
close() {
|
|
199
|
+
this.dispose();
|
|
200
|
+
}
|
|
201
|
+
}
|
package/lib/request.d.ts
CHANGED
|
@@ -6,3 +6,42 @@
|
|
|
6
6
|
* @returns The response body interpreted as JSON
|
|
7
7
|
*/
|
|
8
8
|
export declare function requestAPI<T>(endPoint?: string, init?: RequestInit): Promise<T>;
|
|
9
|
+
/**
|
|
10
|
+
* Column statistics interface
|
|
11
|
+
*/
|
|
12
|
+
export interface IColumnStats {
|
|
13
|
+
column_name: string;
|
|
14
|
+
data_type: string;
|
|
15
|
+
total_rows: number;
|
|
16
|
+
non_null_count: number;
|
|
17
|
+
non_null_percentage: number;
|
|
18
|
+
null_count: number;
|
|
19
|
+
null_percentage: number;
|
|
20
|
+
unique_count: number;
|
|
21
|
+
unique_percentage: number;
|
|
22
|
+
min_value?: number;
|
|
23
|
+
max_value?: number;
|
|
24
|
+
mean?: number;
|
|
25
|
+
median?: number;
|
|
26
|
+
std_dev?: number;
|
|
27
|
+
outlier_count?: number;
|
|
28
|
+
outlier_percentage?: number;
|
|
29
|
+
outlier_lower_bound?: number;
|
|
30
|
+
outlier_upper_bound?: number;
|
|
31
|
+
most_common_value?: string;
|
|
32
|
+
most_common_count?: number;
|
|
33
|
+
min_length?: number;
|
|
34
|
+
max_length?: number;
|
|
35
|
+
avg_length?: number;
|
|
36
|
+
earliest_date?: string;
|
|
37
|
+
latest_date?: string;
|
|
38
|
+
date_range_days?: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Fetch column statistics from the backend
|
|
42
|
+
*
|
|
43
|
+
* @param filePath Path to the data file
|
|
44
|
+
* @param columnName Name of column to analyze
|
|
45
|
+
* @returns Column statistics
|
|
46
|
+
*/
|
|
47
|
+
export declare function fetchColumnStats(filePath: string, columnName: string): Promise<IColumnStats>;
|
package/lib/request.js
CHANGED
|
@@ -33,3 +33,19 @@ export async function requestAPI(endPoint = '', init = {}) {
|
|
|
33
33
|
}
|
|
34
34
|
return data;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Fetch column statistics from the backend
|
|
38
|
+
*
|
|
39
|
+
* @param filePath Path to the data file
|
|
40
|
+
* @param columnName Name of column to analyze
|
|
41
|
+
* @returns Column statistics
|
|
42
|
+
*/
|
|
43
|
+
export async function fetchColumnStats(filePath, columnName) {
|
|
44
|
+
return requestAPI('column-stats', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
path: filePath,
|
|
48
|
+
columnName: columnName
|
|
49
|
+
})
|
|
50
|
+
});
|
|
51
|
+
}
|
package/lib/widget.d.ts
CHANGED
|
@@ -100,6 +100,10 @@ export declare class TabularDataViewer extends Widget {
|
|
|
100
100
|
* Toggle sort on a column
|
|
101
101
|
*/
|
|
102
102
|
private _toggleSort;
|
|
103
|
+
/**
|
|
104
|
+
* Show column statistics modal
|
|
105
|
+
*/
|
|
106
|
+
private _showColumnStats;
|
|
103
107
|
/**
|
|
104
108
|
* Update sort indicators in column headers
|
|
105
109
|
*/
|
package/lib/widget.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Widget } from '@lumino/widgets';
|
|
2
|
-
import { requestAPI } from './request';
|
|
2
|
+
import { requestAPI, fetchColumnStats } from './request';
|
|
3
|
+
import { ColumnStatsModal } from './modal';
|
|
3
4
|
/**
|
|
4
5
|
* Parquet viewer widget
|
|
5
6
|
*/
|
|
@@ -307,6 +308,17 @@ export class TabularDataViewer extends Widget {
|
|
|
307
308
|
const nameSpan = document.createElement('div');
|
|
308
309
|
nameSpan.className = 'jp-TabularDataViewer-columnName';
|
|
309
310
|
nameSpan.textContent = col.name;
|
|
311
|
+
// Add info icon for column statistics
|
|
312
|
+
const infoIcon = document.createElement('span');
|
|
313
|
+
infoIcon.className = 'jp-TabularDataViewer-infoIcon';
|
|
314
|
+
infoIcon.textContent = '🛈';
|
|
315
|
+
infoIcon.title = 'Show column statistics';
|
|
316
|
+
infoIcon.addEventListener('click', async (e) => {
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
e.stopPropagation(); // Prevent sort from triggering
|
|
319
|
+
await this._showColumnStats(col.name);
|
|
320
|
+
});
|
|
321
|
+
nameSpan.appendChild(infoIcon);
|
|
310
322
|
const typeSpan = document.createElement('div');
|
|
311
323
|
typeSpan.className = 'jp-TabularDataViewer-columnType';
|
|
312
324
|
typeSpan.textContent = this._simplifyType(col.type);
|
|
@@ -536,6 +548,22 @@ export class TabularDataViewer extends Widget {
|
|
|
536
548
|
this._updateSortIndicators();
|
|
537
549
|
this._loadData(true);
|
|
538
550
|
}
|
|
551
|
+
/**
|
|
552
|
+
* Show column statistics modal
|
|
553
|
+
*/
|
|
554
|
+
async _showColumnStats(columnName) {
|
|
555
|
+
try {
|
|
556
|
+
// Show loading indicator (we could add a spinner here)
|
|
557
|
+
const stats = await fetchColumnStats(this._filePath, columnName);
|
|
558
|
+
const modal = new ColumnStatsModal(stats);
|
|
559
|
+
modal.show();
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
console.error('Failed to load column statistics:', error);
|
|
563
|
+
// Could show an error message to user
|
|
564
|
+
alert(`Failed to load statistics for column "${columnName}": ${error}`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
539
567
|
/**
|
|
540
568
|
* Update sort indicators in column headers
|
|
541
569
|
*/
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -73,9 +73,9 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
73
73
|
autoStart: true,
|
|
74
74
|
requires: [ISettingRegistry],
|
|
75
75
|
activate: async (app: JupyterFrontEnd, settingRegistry: ISettingRegistry) => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
console.log(
|
|
77
|
+
'JupyterLab extension jupyterlab_tabular_data_viewer_extension is activated!'
|
|
78
|
+
);
|
|
79
79
|
|
|
80
80
|
const { docRegistry, commands, contextMenu } = app;
|
|
81
81
|
|
package/src/modal.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Widget } from '@lumino/widgets';
|
|
2
|
+
import { IColumnStats } from './request';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Modal dialog widget for displaying column statistics
|
|
6
|
+
*/
|
|
7
|
+
export class ColumnStatsModal extends Widget {
|
|
8
|
+
private _stats: IColumnStats;
|
|
9
|
+
|
|
10
|
+
constructor(stats: IColumnStats) {
|
|
11
|
+
super();
|
|
12
|
+
this._stats = stats;
|
|
13
|
+
this.addClass('jp-ColumnStatsModal');
|
|
14
|
+
this._render();
|
|
15
|
+
this._setupEventListeners();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Render the modal content
|
|
20
|
+
*/
|
|
21
|
+
private _render(): void {
|
|
22
|
+
const content = document.createElement('div');
|
|
23
|
+
content.className = 'jp-ColumnStatsModal-content';
|
|
24
|
+
|
|
25
|
+
// Header with column name and close button
|
|
26
|
+
const header = document.createElement('div');
|
|
27
|
+
header.className = 'jp-ColumnStatsModal-header';
|
|
28
|
+
const title = document.createElement('h3');
|
|
29
|
+
title.textContent = `Column: ${this._stats.column_name}`;
|
|
30
|
+
const closeBtn = document.createElement('button');
|
|
31
|
+
closeBtn.className = 'jp-ColumnStatsModal-close';
|
|
32
|
+
closeBtn.textContent = '×';
|
|
33
|
+
closeBtn.onclick = () => this.close();
|
|
34
|
+
header.appendChild(title);
|
|
35
|
+
header.appendChild(closeBtn);
|
|
36
|
+
content.appendChild(header);
|
|
37
|
+
|
|
38
|
+
// Copy JSON button
|
|
39
|
+
const copySection = document.createElement('div');
|
|
40
|
+
copySection.className = 'jp-ColumnStatsModal-copy';
|
|
41
|
+
const copyBtn = document.createElement('button');
|
|
42
|
+
copyBtn.className = 'jp-ColumnStatsModal-copyButton';
|
|
43
|
+
copyBtn.textContent = 'Copy Stats as JSON';
|
|
44
|
+
copyBtn.onclick = () => this._copyStatsAsJson();
|
|
45
|
+
copySection.appendChild(copyBtn);
|
|
46
|
+
content.appendChild(copySection);
|
|
47
|
+
|
|
48
|
+
// Data type
|
|
49
|
+
const typeDiv = document.createElement('div');
|
|
50
|
+
typeDiv.className = 'jp-ColumnStatsModal-type';
|
|
51
|
+
typeDiv.textContent = `Type: ${this._stats.data_type}`;
|
|
52
|
+
content.appendChild(typeDiv);
|
|
53
|
+
|
|
54
|
+
// Data Summary section
|
|
55
|
+
const summarySection = document.createElement('div');
|
|
56
|
+
summarySection.className = 'jp-ColumnStatsModal-section';
|
|
57
|
+
const summaryTitle = document.createElement('h4');
|
|
58
|
+
summaryTitle.textContent = 'Data Summary';
|
|
59
|
+
summarySection.appendChild(summaryTitle);
|
|
60
|
+
|
|
61
|
+
const summaryList = document.createElement('ul');
|
|
62
|
+
summaryList.innerHTML = `
|
|
63
|
+
<li>Total rows: ${this._formatNumber(this._stats.total_rows)}</li>
|
|
64
|
+
<li>Non-null: ${this._formatNumber(this._stats.non_null_count)} (${this._stats.non_null_percentage}%)</li>
|
|
65
|
+
<li>Null: ${this._formatNumber(this._stats.null_count)} (${this._stats.null_percentage}%)</li>
|
|
66
|
+
<li>Unique values: ${this._formatNumber(this._stats.unique_count)} (${this._stats.unique_percentage}%)</li>
|
|
67
|
+
`;
|
|
68
|
+
summarySection.appendChild(summaryList);
|
|
69
|
+
content.appendChild(summarySection);
|
|
70
|
+
|
|
71
|
+
// Numeric statistics
|
|
72
|
+
if (this._stats.data_type === 'int' || this._stats.data_type === 'float') {
|
|
73
|
+
const numericSection = document.createElement('div');
|
|
74
|
+
numericSection.className = 'jp-ColumnStatsModal-section';
|
|
75
|
+
const numericTitle = document.createElement('h4');
|
|
76
|
+
numericTitle.textContent = 'Numeric Statistics';
|
|
77
|
+
numericSection.appendChild(numericTitle);
|
|
78
|
+
|
|
79
|
+
const numericList = document.createElement('ul');
|
|
80
|
+
const items: string[] = [];
|
|
81
|
+
|
|
82
|
+
if (this._stats.min_value !== undefined) {
|
|
83
|
+
items.push(`Min: ${this._formatNumber(this._stats.min_value)}`);
|
|
84
|
+
}
|
|
85
|
+
if (this._stats.max_value !== undefined) {
|
|
86
|
+
items.push(`Max: ${this._formatNumber(this._stats.max_value)}`);
|
|
87
|
+
}
|
|
88
|
+
if (this._stats.mean !== undefined) {
|
|
89
|
+
items.push(`Mean: ${this._formatNumber(this._stats.mean)}`);
|
|
90
|
+
}
|
|
91
|
+
if (this._stats.median !== undefined) {
|
|
92
|
+
items.push(`Median: ${this._formatNumber(this._stats.median)}`);
|
|
93
|
+
}
|
|
94
|
+
if (this._stats.std_dev !== undefined) {
|
|
95
|
+
items.push(`Std Dev: ${this._formatNumber(this._stats.std_dev)}`);
|
|
96
|
+
}
|
|
97
|
+
if (this._stats.outlier_count !== undefined) {
|
|
98
|
+
items.push(`Outliers (IQR×1.5): ${this._formatNumber(this._stats.outlier_count)} (${this._stats.outlier_percentage}%)`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
numericList.innerHTML = items.map(item => `<li>${item}</li>`).join('');
|
|
102
|
+
numericSection.appendChild(numericList);
|
|
103
|
+
content.appendChild(numericSection);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// String statistics
|
|
107
|
+
if (this._stats.data_type === 'string') {
|
|
108
|
+
const stringSection = document.createElement('div');
|
|
109
|
+
stringSection.className = 'jp-ColumnStatsModal-section';
|
|
110
|
+
const stringTitle = document.createElement('h4');
|
|
111
|
+
stringTitle.textContent = 'String Statistics';
|
|
112
|
+
stringSection.appendChild(stringTitle);
|
|
113
|
+
|
|
114
|
+
const stringList = document.createElement('ul');
|
|
115
|
+
const items: string[] = [];
|
|
116
|
+
|
|
117
|
+
if (this._stats.most_common_value !== undefined) {
|
|
118
|
+
items.push(`Most common: "${this._stats.most_common_value}" (${this._stats.most_common_count})`);
|
|
119
|
+
}
|
|
120
|
+
if (this._stats.min_length !== undefined) {
|
|
121
|
+
items.push(`Min length: ${this._stats.min_length} characters`);
|
|
122
|
+
}
|
|
123
|
+
if (this._stats.max_length !== undefined) {
|
|
124
|
+
items.push(`Max length: ${this._stats.max_length} characters`);
|
|
125
|
+
}
|
|
126
|
+
if (this._stats.avg_length !== undefined) {
|
|
127
|
+
items.push(`Avg length: ${this._formatNumber(this._stats.avg_length)} characters`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
stringList.innerHTML = items.map(item => `<li>${item}</li>`).join('');
|
|
131
|
+
stringSection.appendChild(stringList);
|
|
132
|
+
content.appendChild(stringSection);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Date/datetime statistics
|
|
136
|
+
if (this._stats.data_type === 'date' || this._stats.data_type === 'datetime') {
|
|
137
|
+
const dateSection = document.createElement('div');
|
|
138
|
+
dateSection.className = 'jp-ColumnStatsModal-section';
|
|
139
|
+
const dateTitle = document.createElement('h4');
|
|
140
|
+
dateTitle.textContent = 'Date Range';
|
|
141
|
+
dateSection.appendChild(dateTitle);
|
|
142
|
+
|
|
143
|
+
const dateList = document.createElement('ul');
|
|
144
|
+
const items: string[] = [];
|
|
145
|
+
|
|
146
|
+
if (this._stats.earliest_date) {
|
|
147
|
+
items.push(`Earliest: ${this._stats.earliest_date}`);
|
|
148
|
+
}
|
|
149
|
+
if (this._stats.latest_date) {
|
|
150
|
+
items.push(`Latest: ${this._stats.latest_date}`);
|
|
151
|
+
}
|
|
152
|
+
if (this._stats.date_range_days !== undefined) {
|
|
153
|
+
items.push(`Span: ${this._formatNumber(this._stats.date_range_days)} days`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
dateList.innerHTML = items.map(item => `<li>${item}</li>`).join('');
|
|
157
|
+
dateSection.appendChild(dateList);
|
|
158
|
+
content.appendChild(dateSection);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.node.appendChild(content);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Format number with thousands separator
|
|
166
|
+
*/
|
|
167
|
+
private _formatNumber(num: number): string {
|
|
168
|
+
return num.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Copy statistics as JSON to clipboard
|
|
173
|
+
*/
|
|
174
|
+
private async _copyStatsAsJson(): Promise<void> {
|
|
175
|
+
try {
|
|
176
|
+
const json = JSON.stringify(this._stats, null, 2);
|
|
177
|
+
await navigator.clipboard.writeText(json);
|
|
178
|
+
// Provide visual feedback
|
|
179
|
+
const btn = this.node.querySelector('.jp-ColumnStatsModal-copyButton');
|
|
180
|
+
if (btn) {
|
|
181
|
+
const originalText = btn.textContent;
|
|
182
|
+
btn.textContent = 'Copied!';
|
|
183
|
+
setTimeout(() => {
|
|
184
|
+
btn.textContent = originalText;
|
|
185
|
+
}, 2000);
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error('Failed to copy stats to clipboard:', error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Setup event listeners for closing the modal
|
|
194
|
+
*/
|
|
195
|
+
private _setupEventListeners(): void {
|
|
196
|
+
// Close on ESC key
|
|
197
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
198
|
+
if (event.key === 'Escape') {
|
|
199
|
+
this.close();
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
document.addEventListener('keydown', handleKeydown);
|
|
203
|
+
this.disposed.connect(() => {
|
|
204
|
+
document.removeEventListener('keydown', handleKeydown);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Close on backdrop click
|
|
208
|
+
this.node.addEventListener('click', (event: MouseEvent) => {
|
|
209
|
+
if (event.target === this.node) {
|
|
210
|
+
this.close();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Show the modal
|
|
217
|
+
*/
|
|
218
|
+
show(): void {
|
|
219
|
+
Widget.attach(this, document.body);
|
|
220
|
+
this.node.focus();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Close and dispose the modal
|
|
225
|
+
*/
|
|
226
|
+
close(): void {
|
|
227
|
+
this.dispose();
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/request.ts
CHANGED
|
@@ -44,3 +44,58 @@ export async function requestAPI<T>(
|
|
|
44
44
|
|
|
45
45
|
return data;
|
|
46
46
|
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Column statistics interface
|
|
50
|
+
*/
|
|
51
|
+
export interface IColumnStats {
|
|
52
|
+
column_name: string;
|
|
53
|
+
data_type: string;
|
|
54
|
+
total_rows: number;
|
|
55
|
+
non_null_count: number;
|
|
56
|
+
non_null_percentage: number;
|
|
57
|
+
null_count: number;
|
|
58
|
+
null_percentage: number;
|
|
59
|
+
unique_count: number;
|
|
60
|
+
unique_percentage: number;
|
|
61
|
+
// Numeric stats
|
|
62
|
+
min_value?: number;
|
|
63
|
+
max_value?: number;
|
|
64
|
+
mean?: number;
|
|
65
|
+
median?: number;
|
|
66
|
+
std_dev?: number;
|
|
67
|
+
outlier_count?: number;
|
|
68
|
+
outlier_percentage?: number;
|
|
69
|
+
outlier_lower_bound?: number;
|
|
70
|
+
outlier_upper_bound?: number;
|
|
71
|
+
// String stats
|
|
72
|
+
most_common_value?: string;
|
|
73
|
+
most_common_count?: number;
|
|
74
|
+
min_length?: number;
|
|
75
|
+
max_length?: number;
|
|
76
|
+
avg_length?: number;
|
|
77
|
+
// Date stats
|
|
78
|
+
earliest_date?: string;
|
|
79
|
+
latest_date?: string;
|
|
80
|
+
date_range_days?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fetch column statistics from the backend
|
|
85
|
+
*
|
|
86
|
+
* @param filePath Path to the data file
|
|
87
|
+
* @param columnName Name of column to analyze
|
|
88
|
+
* @returns Column statistics
|
|
89
|
+
*/
|
|
90
|
+
export async function fetchColumnStats(
|
|
91
|
+
filePath: string,
|
|
92
|
+
columnName: string
|
|
93
|
+
): Promise<IColumnStats> {
|
|
94
|
+
return requestAPI<IColumnStats>('column-stats', {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
path: filePath,
|
|
98
|
+
columnName: columnName
|
|
99
|
+
})
|
|
100
|
+
});
|
|
101
|
+
}
|
package/src/widget.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Widget } from '@lumino/widgets';
|
|
2
|
-
import { requestAPI } from './request';
|
|
2
|
+
import { requestAPI, fetchColumnStats } from './request';
|
|
3
|
+
import { ColumnStatsModal } from './modal';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Column metadata interface
|
|
@@ -346,6 +347,18 @@ export class TabularDataViewer extends Widget {
|
|
|
346
347
|
nameSpan.className = 'jp-TabularDataViewer-columnName';
|
|
347
348
|
nameSpan.textContent = col.name;
|
|
348
349
|
|
|
350
|
+
// Add info icon for column statistics
|
|
351
|
+
const infoIcon = document.createElement('span');
|
|
352
|
+
infoIcon.className = 'jp-TabularDataViewer-infoIcon';
|
|
353
|
+
infoIcon.textContent = '🛈';
|
|
354
|
+
infoIcon.title = 'Show column statistics';
|
|
355
|
+
infoIcon.addEventListener('click', async (e: MouseEvent) => {
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
e.stopPropagation(); // Prevent sort from triggering
|
|
358
|
+
await this._showColumnStats(col.name);
|
|
359
|
+
});
|
|
360
|
+
nameSpan.appendChild(infoIcon);
|
|
361
|
+
|
|
349
362
|
const typeSpan = document.createElement('div');
|
|
350
363
|
typeSpan.className = 'jp-TabularDataViewer-columnType';
|
|
351
364
|
typeSpan.textContent = this._simplifyType(col.type);
|
|
@@ -666,6 +679,22 @@ export class TabularDataViewer extends Widget {
|
|
|
666
679
|
this._loadData(true);
|
|
667
680
|
}
|
|
668
681
|
|
|
682
|
+
/**
|
|
683
|
+
* Show column statistics modal
|
|
684
|
+
*/
|
|
685
|
+
private async _showColumnStats(columnName: string): Promise<void> {
|
|
686
|
+
try {
|
|
687
|
+
// Show loading indicator (we could add a spinner here)
|
|
688
|
+
const stats = await fetchColumnStats(this._filePath, columnName);
|
|
689
|
+
const modal = new ColumnStatsModal(stats);
|
|
690
|
+
modal.show();
|
|
691
|
+
} catch (error) {
|
|
692
|
+
console.error('Failed to load column statistics:', error);
|
|
693
|
+
// Could show an error message to user
|
|
694
|
+
alert(`Failed to load statistics for column "${columnName}": ${error}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
669
698
|
/**
|
|
670
699
|
* Update sort indicators in column headers
|
|
671
700
|
*/
|
package/style/base.css
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
.jp-TabularDataViewer-table {
|
|
22
|
-
border-collapse:
|
|
22
|
+
border-collapse: separate;
|
|
23
|
+
border-spacing: 0;
|
|
23
24
|
table-layout: fixed;
|
|
24
25
|
font-family: var(--jp-code-font-family);
|
|
25
26
|
font-size: var(--jp-code-font-size);
|
|
@@ -44,8 +45,10 @@
|
|
|
44
45
|
.jp-TabularDataViewer-filterCell {
|
|
45
46
|
padding: 4px 8px;
|
|
46
47
|
border-bottom: 1px solid var(--jp-border-color1);
|
|
47
|
-
border-right: 1px solid var(--jp-border-
|
|
48
|
+
border-right: 1px solid var(--jp-border-color0);
|
|
48
49
|
box-sizing: border-box;
|
|
50
|
+
background-color: var(--jp-layout-color2);
|
|
51
|
+
position: relative;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
.jp-TabularDataViewer-filterCell:last-child {
|
|
@@ -57,7 +60,7 @@
|
|
|
57
60
|
padding: 4px 6px;
|
|
58
61
|
border: 1px solid var(--jp-border-color2);
|
|
59
62
|
border-radius: 2px;
|
|
60
|
-
background-color: var(--jp-
|
|
63
|
+
background-color: var(--jp-layout-color0);
|
|
61
64
|
color: var(--jp-ui-font-color1);
|
|
62
65
|
font-family: var(--jp-ui-font-family);
|
|
63
66
|
font-size: var(--jp-ui-font-size1);
|
|
@@ -85,7 +88,7 @@
|
|
|
85
88
|
text-align: left;
|
|
86
89
|
font-weight: 600;
|
|
87
90
|
border-bottom: 2px solid var(--jp-border-color1);
|
|
88
|
-
border-right: 1px solid var(--jp-border-
|
|
91
|
+
border-right: 1px solid var(--jp-border-color0);
|
|
89
92
|
background-color: var(--jp-layout-color2);
|
|
90
93
|
position: relative;
|
|
91
94
|
min-width: 120px;
|
|
@@ -156,6 +159,7 @@
|
|
|
156
159
|
.jp-TabularDataViewer-cell {
|
|
157
160
|
padding: 6px 12px;
|
|
158
161
|
border-right: 1px solid var(--jp-border-color2);
|
|
162
|
+
border-bottom: 1px solid var(--jp-border-color2);
|
|
159
163
|
color: var(--jp-ui-font-color1);
|
|
160
164
|
word-break: break-word;
|
|
161
165
|
max-width: 400px;
|
|
@@ -245,7 +249,7 @@
|
|
|
245
249
|
}
|
|
246
250
|
|
|
247
251
|
[data-jp-theme-light='false'] .jp-TabularDataViewer-filterInput {
|
|
248
|
-
background-color: var(--jp-
|
|
252
|
+
background-color: var(--jp-layout-color0);
|
|
249
253
|
color: var(--jp-ui-font-color0);
|
|
250
254
|
}
|
|
251
255
|
|
|
@@ -267,3 +271,147 @@
|
|
|
267
271
|
.jp-TabularDataViewer-container::-webkit-scrollbar-thumb:hover {
|
|
268
272
|
background: var(--jp-border-color1);
|
|
269
273
|
}
|
|
274
|
+
|
|
275
|
+
/* Column Info Icon */
|
|
276
|
+
.jp-TabularDataViewer-infoIcon {
|
|
277
|
+
margin-left: 6px;
|
|
278
|
+
font-size: 16px;
|
|
279
|
+
color: transparent;
|
|
280
|
+
cursor: pointer;
|
|
281
|
+
transition: color 0.2s ease;
|
|
282
|
+
user-select: none;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.jp-TabularDataViewer-headerCell:hover .jp-TabularDataViewer-infoIcon {
|
|
286
|
+
color: var(--jp-brand-color1) !important;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.jp-TabularDataViewer-infoIcon:hover {
|
|
290
|
+
color: var(--jp-brand-color1) !important;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* Column Statistics Modal */
|
|
294
|
+
.jp-ColumnStatsModal {
|
|
295
|
+
position: fixed;
|
|
296
|
+
top: 0;
|
|
297
|
+
left: 0;
|
|
298
|
+
width: 100%;
|
|
299
|
+
height: 100%;
|
|
300
|
+
background-color: rgba(0, 0, 0, 0.5);
|
|
301
|
+
display: flex;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: center;
|
|
304
|
+
z-index: 10000;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.jp-ColumnStatsModal-content {
|
|
308
|
+
background-color: var(--jp-layout-color1);
|
|
309
|
+
border-radius: 8px;
|
|
310
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
311
|
+
max-width: 500px;
|
|
312
|
+
max-height: 80vh;
|
|
313
|
+
overflow-y: auto;
|
|
314
|
+
padding: 20px;
|
|
315
|
+
color: var(--jp-ui-font-color1);
|
|
316
|
+
font-family: var(--jp-ui-font-family);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.jp-ColumnStatsModal-header {
|
|
320
|
+
display: flex;
|
|
321
|
+
justify-content: space-between;
|
|
322
|
+
align-items: center;
|
|
323
|
+
margin-bottom: 16px;
|
|
324
|
+
padding-bottom: 12px;
|
|
325
|
+
border-bottom: 2px solid var(--jp-border-color1);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.jp-ColumnStatsModal-header h3 {
|
|
329
|
+
margin: 0;
|
|
330
|
+
font-size: 18px;
|
|
331
|
+
font-weight: 600;
|
|
332
|
+
color: var(--jp-ui-font-color1);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.jp-ColumnStatsModal-close {
|
|
336
|
+
background: none;
|
|
337
|
+
border: none;
|
|
338
|
+
font-size: 28px;
|
|
339
|
+
line-height: 1;
|
|
340
|
+
cursor: pointer;
|
|
341
|
+
color: var(--jp-ui-font-color2);
|
|
342
|
+
padding: 0;
|
|
343
|
+
width: 32px;
|
|
344
|
+
height: 32px;
|
|
345
|
+
display: flex;
|
|
346
|
+
align-items: center;
|
|
347
|
+
justify-content: center;
|
|
348
|
+
border-radius: 4px;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.jp-ColumnStatsModal-close:hover {
|
|
352
|
+
background-color: var(--jp-layout-color2);
|
|
353
|
+
color: var(--jp-ui-font-color1);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.jp-ColumnStatsModal-copy {
|
|
357
|
+
margin-bottom: 16px;
|
|
358
|
+
text-align: center;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.jp-ColumnStatsModal-copyButton {
|
|
362
|
+
background-color: var(--jp-brand-color1);
|
|
363
|
+
color: white;
|
|
364
|
+
border: none;
|
|
365
|
+
padding: 8px 16px;
|
|
366
|
+
border-radius: 4px;
|
|
367
|
+
cursor: pointer;
|
|
368
|
+
font-size: 13px;
|
|
369
|
+
font-weight: 500;
|
|
370
|
+
font-family: var(--jp-ui-font-family);
|
|
371
|
+
transition: background-color 0.2s ease;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.jp-ColumnStatsModal-copyButton:hover {
|
|
375
|
+
background-color: var(--jp-brand-color2);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.jp-ColumnStatsModal-copyButton:active {
|
|
379
|
+
background-color: var(--jp-brand-color3);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.jp-ColumnStatsModal-type {
|
|
383
|
+
font-size: 14px;
|
|
384
|
+
color: var(--jp-ui-font-color2);
|
|
385
|
+
margin-bottom: 20px;
|
|
386
|
+
font-style: italic;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.jp-ColumnStatsModal-section {
|
|
390
|
+
margin-bottom: 20px;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.jp-ColumnStatsModal-section h4 {
|
|
394
|
+
margin: 0 0 8px 0;
|
|
395
|
+
font-size: 15px;
|
|
396
|
+
font-weight: 600;
|
|
397
|
+
color: var(--jp-ui-font-color1);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.jp-ColumnStatsModal-section ul {
|
|
401
|
+
list-style: none;
|
|
402
|
+
padding: 0;
|
|
403
|
+
margin: 0;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.jp-ColumnStatsModal-section li {
|
|
407
|
+
padding: 4px 0;
|
|
408
|
+
font-size: 14px;
|
|
409
|
+
color: var(--jp-ui-font-color1);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.jp-ColumnStatsModal-section li::before {
|
|
413
|
+
content: '• ';
|
|
414
|
+
color: var(--jp-brand-color1);
|
|
415
|
+
font-weight: bold;
|
|
416
|
+
margin-right: 8px;
|
|
417
|
+
}
|