pivotgrid-js 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +29 -0
- package/LICENSE.commercial +60 -0
- package/README.dev.md +247 -0
- package/README.md +253 -0
- package/config/config-editor.css +298 -0
- package/config/config-editor.html +202 -0
- package/config/config-editor.js +687 -0
- package/demo_data/demo-config.js +38 -0
- package/demo_data/demo-data.js +1 -0
- package/dist/pivotgrid.cjs.js +2867 -0
- package/dist/pivotgrid.css +1091 -0
- package/dist/pivotgrid.esm.js +2867 -0
- package/dist/pivotgrid.js +2865 -0
- package/dist/pivotgrid.min.js +18 -0
- package/engine/aggregator.js +193 -0
- package/engine/column-store.js +99 -0
- package/engine/dictionary-encoder.js +30 -0
- package/package.json +50 -0
- package/providers/array-provider.js +255 -0
- package/providers/rest-provider.js +296 -0
- package/server/.env +5 -0
- package/server/README.md +88 -0
- package/server/configs/main_config.json +112 -0
- package/server/connectors/__init__.py +0 -0
- package/server/connectors/__pycache__/postgresql.cpython-312.pyc +0 -0
- package/server/connectors/postgresql.py +34 -0
- package/server/server.py +328 -0
- package/src/field-zones.css +167 -0
- package/src/field-zones.js +344 -0
- package/src/filter-manager.js +290 -0
- package/src/pivot.css +252 -0
- package/src/pivot.js +919 -0
- package/widget/cache-manager.js +253 -0
- package/widget/i18n.js +179 -0
- package/widget/pivot-widget.js +572 -0
- package/widget/widget.css +672 -0
package/server/server.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server.py — DB proxy and config storage
|
|
3
|
+
|
|
4
|
+
Endpoints:
|
|
5
|
+
POST /query → execute SELECT, return JSON array
|
|
6
|
+
GET /configs → list config names
|
|
7
|
+
GET /configs/{name} → get config by name
|
|
8
|
+
POST /configs/{name} → save config by name
|
|
9
|
+
GET /server-config → DB settings (no password) + connector list
|
|
10
|
+
POST /server-config → save DB settings to .env
|
|
11
|
+
|
|
12
|
+
Connectors: add a file to ./connectors/ with NAME and execute_query(query).
|
|
13
|
+
Configs: stored in ./configs/{name}.json
|
|
14
|
+
Settings: stored in .env
|
|
15
|
+
|
|
16
|
+
Installation (for PostgreSQL):
|
|
17
|
+
pip install psycopg2-binary
|
|
18
|
+
|
|
19
|
+
Start:
|
|
20
|
+
python server.py
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import gzip
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import importlib.util
|
|
28
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
29
|
+
from socketserver import ThreadingMixIn
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Paths ─────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
35
|
+
ENV_PATH = os.path.join(BASE_DIR, '.env')
|
|
36
|
+
CONFIGS_DIR = os.path.join(BASE_DIR, 'configs')
|
|
37
|
+
CONNECTORS_DIR = os.path.join(BASE_DIR, 'connectors')
|
|
38
|
+
|
|
39
|
+
os.makedirs(CONFIGS_DIR, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
# ── Load .env ──────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def load_env(path):
|
|
44
|
+
if not os.path.exists(path):
|
|
45
|
+
return
|
|
46
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
47
|
+
for line in f:
|
|
48
|
+
line = line.strip()
|
|
49
|
+
if not line or line.startswith('#') or '=' not in line:
|
|
50
|
+
continue
|
|
51
|
+
key, _, val = line.partition('=')
|
|
52
|
+
os.environ[key.strip()] = val.strip()
|
|
53
|
+
|
|
54
|
+
load_env(ENV_PATH)
|
|
55
|
+
|
|
56
|
+
# ── Connectors ─────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def load_connectors():
|
|
59
|
+
"""Scans connectors/ and loads all modules that have NAME and execute_query."""
|
|
60
|
+
connectors = {}
|
|
61
|
+
if not os.path.exists(CONNECTORS_DIR):
|
|
62
|
+
return connectors
|
|
63
|
+
for fname in sorted(os.listdir(CONNECTORS_DIR)):
|
|
64
|
+
if not fname.endswith('.py') or fname.startswith('_'):
|
|
65
|
+
continue
|
|
66
|
+
module_name = fname[:-3]
|
|
67
|
+
path = os.path.join(CONNECTORS_DIR, fname)
|
|
68
|
+
try:
|
|
69
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
70
|
+
module = importlib.util.module_from_spec(spec)
|
|
71
|
+
spec.loader.exec_module(module)
|
|
72
|
+
if hasattr(module, 'NAME') and hasattr(module, 'execute_query'):
|
|
73
|
+
connectors[module_name] = module
|
|
74
|
+
print(f'Connector loaded: {module.NAME} ({module_name})')
|
|
75
|
+
except Exception as e:
|
|
76
|
+
print(f'Connector error [{module_name}]: {e}')
|
|
77
|
+
return connectors
|
|
78
|
+
|
|
79
|
+
CONNECTORS = load_connectors()
|
|
80
|
+
|
|
81
|
+
def get_active_connector():
|
|
82
|
+
"""Returns the active connector based on DB_CONNECTOR from .env."""
|
|
83
|
+
name = os.getenv('DB_CONNECTOR', '')
|
|
84
|
+
if name and name in CONNECTORS:
|
|
85
|
+
return CONNECTORS[name]
|
|
86
|
+
# Fall back to the first available
|
|
87
|
+
if CONNECTORS:
|
|
88
|
+
return next(iter(CONNECTORS.values()))
|
|
89
|
+
raise RuntimeError('No connectors available in ./connectors/')
|
|
90
|
+
|
|
91
|
+
# ── Config ──────────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
PORT = int(os.getenv("PORT", "8000"))
|
|
94
|
+
|
|
95
|
+
CORS_HEADERS = {
|
|
96
|
+
'Access-Control-Allow-Origin': '*',
|
|
97
|
+
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
|
98
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def valid_config_name(name):
|
|
102
|
+
return bool(re.match(r'^[\w\-]+$', name))
|
|
103
|
+
|
|
104
|
+
def config_path(name):
|
|
105
|
+
return os.path.join(CONFIGS_DIR, f"{name}.json")
|
|
106
|
+
|
|
107
|
+
def save_env(data):
|
|
108
|
+
"""Saves settings to .env without touching other variables."""
|
|
109
|
+
lines = []
|
|
110
|
+
protected = {'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_CONNECTOR'}
|
|
111
|
+
if os.path.exists(ENV_PATH):
|
|
112
|
+
with open(ENV_PATH, 'r', encoding='utf-8') as f:
|
|
113
|
+
for line in f:
|
|
114
|
+
key = line.partition('=')[0].strip()
|
|
115
|
+
if key not in protected:
|
|
116
|
+
lines.append(line.rstrip('\n'))
|
|
117
|
+
|
|
118
|
+
if 'host' in data: lines.append(f"DB_HOST={data['host']}")
|
|
119
|
+
if 'port' in data: lines.append(f"DB_PORT={data['port']}")
|
|
120
|
+
if 'dbname' in data: lines.append(f"DB_NAME={data['dbname']}")
|
|
121
|
+
if 'user' in data: lines.append(f"DB_USER={data['user']}")
|
|
122
|
+
if 'password' in data and data['password']:
|
|
123
|
+
lines.append(f"DB_PASSWORD={data['password']}")
|
|
124
|
+
if 'connector' in data: lines.append(f"DB_CONNECTOR={data['connector']}")
|
|
125
|
+
|
|
126
|
+
with open(ENV_PATH, 'w', encoding='utf-8') as f:
|
|
127
|
+
f.write('\n'.join(lines) + '\n')
|
|
128
|
+
|
|
129
|
+
# Update os.environ immediately
|
|
130
|
+
for key, env_key in [('host','DB_HOST'),('port','DB_PORT'),('dbname','DB_NAME'),
|
|
131
|
+
('user','DB_USER'),('connector','DB_CONNECTOR')]:
|
|
132
|
+
if key in data:
|
|
133
|
+
os.environ[env_key] = str(data[key])
|
|
134
|
+
if 'password' in data and data['password']:
|
|
135
|
+
os.environ['DB_PASSWORD'] = data['password']
|
|
136
|
+
|
|
137
|
+
# ── Handler ───────────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
class Handler(BaseHTTPRequestHandler):
|
|
140
|
+
protocol_version = 'HTTP/1.1'
|
|
141
|
+
|
|
142
|
+
def do_OPTIONS(self):
|
|
143
|
+
self._send(200, '')
|
|
144
|
+
|
|
145
|
+
def do_DELETE(self):
|
|
146
|
+
m = re.match(r'^/configs/([\w\-]+)$', self.path)
|
|
147
|
+
if m:
|
|
148
|
+
name = m.group(1)
|
|
149
|
+
if not valid_config_name(name):
|
|
150
|
+
self._send(400, json.dumps({'error': 'Invalid config name'}))
|
|
151
|
+
return
|
|
152
|
+
path = config_path(name)
|
|
153
|
+
if not os.path.exists(path):
|
|
154
|
+
self._send(404, json.dumps({'error': f'Config "{name}" not found'}))
|
|
155
|
+
return
|
|
156
|
+
os.remove(path)
|
|
157
|
+
print(f'Config deleted: {name}')
|
|
158
|
+
self._send(200, json.dumps({'ok': True, 'name': name}))
|
|
159
|
+
return
|
|
160
|
+
self._send(404, json.dumps({'error': 'Not found'}))
|
|
161
|
+
# ── GET ───────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
def do_GET(self):
|
|
164
|
+
# GET /configs → list of config names
|
|
165
|
+
if self.path == '/configs':
|
|
166
|
+
names = sorted(f[:-5] for f in os.listdir(CONFIGS_DIR) if f.endswith('.json'))
|
|
167
|
+
self._send(200, json.dumps(names))
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# GET /configs/{name} → config by name
|
|
171
|
+
m = re.match(r'^/configs/([\w\-]+)$', self.path)
|
|
172
|
+
if m:
|
|
173
|
+
name = m.group(1)
|
|
174
|
+
if not valid_config_name(name):
|
|
175
|
+
self._send(400, json.dumps({'error': 'Invalid config name'}))
|
|
176
|
+
return
|
|
177
|
+
path = config_path(name)
|
|
178
|
+
if not os.path.exists(path):
|
|
179
|
+
self._send(404, json.dumps({'error': f'Config "{name}" not found'}))
|
|
180
|
+
return
|
|
181
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
182
|
+
self._send(200, f.read())
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
# GET /server-config → DB settings (no password) + connectors
|
|
186
|
+
if self.path == '/server-config':
|
|
187
|
+
active = os.getenv('DB_CONNECTOR', next(iter(CONNECTORS), ''))
|
|
188
|
+
result = {
|
|
189
|
+
'host': os.getenv('DB_HOST', 'localhost'),
|
|
190
|
+
'port': os.getenv('DB_PORT', '5432'),
|
|
191
|
+
'dbname': os.getenv('DB_NAME', 'postgres'),
|
|
192
|
+
'user': os.getenv('DB_USER', 'postgres'),
|
|
193
|
+
'connector': active,
|
|
194
|
+
'connectors': {k: v.NAME for k, v in CONNECTORS.items()},
|
|
195
|
+
}
|
|
196
|
+
self._send(200, json.dumps(result))
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
self._send(404, json.dumps({'error': 'Not found'}))
|
|
200
|
+
|
|
201
|
+
# ── POST ──────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
def do_POST(self):
|
|
204
|
+
length = int(self.headers.get('Content-Length', 0))
|
|
205
|
+
body = self.rfile.read(length)
|
|
206
|
+
|
|
207
|
+
if self.path == '/query':
|
|
208
|
+
self._handle_query(body)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
if self.path == '/server-config':
|
|
212
|
+
self._handle_save_server_config(body)
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
if self.path == '/test-connection':
|
|
216
|
+
self._handle_test_connection(body)
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
m = re.match(r'^/configs/([\w\-]+)$', self.path)
|
|
220
|
+
if m:
|
|
221
|
+
self._handle_save_config(m.group(1), body)
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
self._send(404, json.dumps({'error': 'Not found'}))
|
|
225
|
+
|
|
226
|
+
# ── SQL query ────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
def _handle_query(self, body):
|
|
229
|
+
try:
|
|
230
|
+
payload = json.loads(body)
|
|
231
|
+
except Exception:
|
|
232
|
+
self._send(400, json.dumps({'error': 'Invalid JSON'}))
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
query = payload.get('query', '').strip()
|
|
236
|
+
if not query.upper().startswith('SELECT'):
|
|
237
|
+
self._send(400, json.dumps({'error': 'Only SELECT allowed'}))
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
connector = get_active_connector()
|
|
242
|
+
rows = connector.execute_query(query)
|
|
243
|
+
self._send(200, json.dumps(rows, ensure_ascii=False, default=str))
|
|
244
|
+
except Exception as e:
|
|
245
|
+
self._send(500, json.dumps({'error': str(e)}))
|
|
246
|
+
|
|
247
|
+
# ── Save config ──────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
def _handle_save_config(self, name, body):
|
|
250
|
+
if not valid_config_name(name):
|
|
251
|
+
self._send(400, json.dumps({'error': 'Invalid config name'}))
|
|
252
|
+
return
|
|
253
|
+
try:
|
|
254
|
+
data = json.loads(body)
|
|
255
|
+
with open(config_path(name), 'w', encoding='utf-8') as f:
|
|
256
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
257
|
+
print(f'Config saved: {name}')
|
|
258
|
+
self._send(200, json.dumps({'ok': True, 'name': name}))
|
|
259
|
+
except json.JSONDecodeError:
|
|
260
|
+
self._send(400, json.dumps({'error': 'Invalid JSON'}))
|
|
261
|
+
except Exception as e:
|
|
262
|
+
self._send(500, json.dumps({'error': str(e)}))
|
|
263
|
+
|
|
264
|
+
# ── Save DB settings ─────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
def _handle_save_server_config(self, body):
|
|
267
|
+
try:
|
|
268
|
+
data = json.loads(body)
|
|
269
|
+
save_env(data)
|
|
270
|
+
print('Server config saved')
|
|
271
|
+
self._send(200, json.dumps({'ok': True}))
|
|
272
|
+
except json.JSONDecodeError:
|
|
273
|
+
self._send(400, json.dumps({'error': 'Invalid JSON'}))
|
|
274
|
+
except Exception as e:
|
|
275
|
+
self._send(500, json.dumps({'error': str(e)}))
|
|
276
|
+
|
|
277
|
+
def _handle_test_connection(self, body):
|
|
278
|
+
try:
|
|
279
|
+
data = json.loads(body)
|
|
280
|
+
connector = get_active_connector()
|
|
281
|
+
if not hasattr(connector, 'test_connection'):
|
|
282
|
+
self._send(400, json.dumps({'error': 'Connector does not support connection test'}))
|
|
283
|
+
return
|
|
284
|
+
connector.test_connection(
|
|
285
|
+
host=data.get('host', os.getenv('DB_HOST', 'localhost')),
|
|
286
|
+
port=data.get('port', os.getenv('DB_PORT', '5432')),
|
|
287
|
+
dbname=data.get('dbname', os.getenv('DB_NAME', 'postgres')),
|
|
288
|
+
user=data.get('user', os.getenv('DB_USER', 'postgres')),
|
|
289
|
+
password=data.get('password', os.getenv('DB_PASSWORD', '')),
|
|
290
|
+
)
|
|
291
|
+
self._send(200, json.dumps({'ok': True}))
|
|
292
|
+
except Exception as e:
|
|
293
|
+
self._send(500, json.dumps({'error': str(e)}))
|
|
294
|
+
|
|
295
|
+
# ── HTTP response ─────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
def _send(self, status, body, content_type='application/json; charset=utf-8'):
|
|
298
|
+
encoded = body.encode('utf-8') if isinstance(body, str) else body
|
|
299
|
+
accept_encoding = self.headers.get('Accept-Encoding', '')
|
|
300
|
+
use_gzip = 'gzip' in accept_encoding and len(encoded) > 1024
|
|
301
|
+
if use_gzip:
|
|
302
|
+
encoded = gzip.compress(encoded, compresslevel=6)
|
|
303
|
+
self.send_response(status)
|
|
304
|
+
for k, v in CORS_HEADERS.items():
|
|
305
|
+
self.send_header(k, v)
|
|
306
|
+
self.send_header('Content-Type', content_type)
|
|
307
|
+
self.send_header('Content-Length', str(len(encoded)))
|
|
308
|
+
self.send_header('Connection', 'keep-alive')
|
|
309
|
+
if use_gzip:
|
|
310
|
+
self.send_header('Content-Encoding', 'gzip')
|
|
311
|
+
self.end_headers()
|
|
312
|
+
if encoded:
|
|
313
|
+
self.wfile.write(encoded)
|
|
314
|
+
|
|
315
|
+
def log_message(self, fmt, *args):
|
|
316
|
+
print(f'{self.address_string()} — {fmt % args}')
|
|
317
|
+
|
|
318
|
+
# ── Start ───────────────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
|
321
|
+
daemon_threads = True
|
|
322
|
+
|
|
323
|
+
if __name__ == '__main__':
|
|
324
|
+
server = ThreadingHTTPServer(('localhost', PORT), Handler)
|
|
325
|
+
print(f'Server: http://localhost:{PORT}')
|
|
326
|
+
print(f'Configs: {CONFIGS_DIR}')
|
|
327
|
+
print(f'Connectors: {list(CONNECTORS.keys())}')
|
|
328
|
+
server.serve_forever()
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
.fz-zone--filters { flex: 1.5; }
|
|
2
|
+
|
|
3
|
+
[data-fz-zone="filters"] .fz-chip {
|
|
4
|
+
background: #fff8f0;
|
|
5
|
+
border-color: #f4c27a;
|
|
6
|
+
color: #7a4200;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
[data-fz-zone="filters"] .fz-chip:hover {
|
|
10
|
+
border-color: #e67e22;
|
|
11
|
+
color: #5d3100;
|
|
12
|
+
box-shadow: 0 1px 4px rgba(230,126,34,0.2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
[data-fz-zone="filters"] .fz-chip.fz-chip--active {
|
|
16
|
+
background: #fff0dc;
|
|
17
|
+
border-color: #e67e22;
|
|
18
|
+
font-weight: 500;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.fz-chip-label { cursor: pointer; }
|
|
22
|
+
|
|
23
|
+
/* ── Field Zones ────────────────────────────────────────────────────── */
|
|
24
|
+
|
|
25
|
+
.field-zones {
|
|
26
|
+
display: flex;
|
|
27
|
+
gap: 8px;
|
|
28
|
+
padding: 8px 12px;
|
|
29
|
+
background: #fff;
|
|
30
|
+
border: 1px solid #e0e0e0;
|
|
31
|
+
border-radius: 8px;
|
|
32
|
+
min-height: 48px;
|
|
33
|
+
align-items: stretch;
|
|
34
|
+
margin-bottom: 8px;
|
|
35
|
+
flex-shrink: 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.fz-zone {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
gap: 4px;
|
|
42
|
+
flex: 1;
|
|
43
|
+
min-width: 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.fz-zone--rows { flex: 2; }
|
|
47
|
+
.fz-zone--columns { flex: 1.5; }
|
|
48
|
+
.fz-zone--free { flex: 1.5; }
|
|
49
|
+
|
|
50
|
+
.fz-zone-label {
|
|
51
|
+
font-size: 10px;
|
|
52
|
+
font-weight: 600;
|
|
53
|
+
color: #999;
|
|
54
|
+
text-transform: uppercase;
|
|
55
|
+
letter-spacing: 0.06em;
|
|
56
|
+
padding: 0 4px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.fz-zone-body {
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-wrap: wrap;
|
|
62
|
+
gap: 4px;
|
|
63
|
+
min-height: 33px;
|
|
64
|
+
max-height: 66px;
|
|
65
|
+
overflow-y: auto;
|
|
66
|
+
padding: 4px 6px;
|
|
67
|
+
border: 1.5px dashed #d0d0d0;
|
|
68
|
+
border-radius: 6px;
|
|
69
|
+
background: #fff;
|
|
70
|
+
transition: background 0.15s, border-color 0.15s;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.fz-zone-body.fz-zone--over {
|
|
74
|
+
background: #e8f0fe;
|
|
75
|
+
border-color: #1a73e8;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Chips */
|
|
79
|
+
.fz-chip {
|
|
80
|
+
display: inline-flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: 4px;
|
|
83
|
+
padding: 3px 8px;
|
|
84
|
+
background: #fff;
|
|
85
|
+
border: 1px solid #d0d0d0;
|
|
86
|
+
border-radius: 4px;
|
|
87
|
+
font-size: 12px;
|
|
88
|
+
color: #333;
|
|
89
|
+
cursor: grab;
|
|
90
|
+
user-select: none;
|
|
91
|
+
white-space: nowrap;
|
|
92
|
+
transition: box-shadow 0.1s, border-color 0.1s;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.fz-chip:hover {
|
|
96
|
+
border-color: #1a73e8;
|
|
97
|
+
color: #1a73e8;
|
|
98
|
+
box-shadow: 0 1px 4px rgba(26,115,232,0.15);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* Chips in rows/columns — slightly coloured */
|
|
102
|
+
[data-fz-zone="rows"] .fz-chip {
|
|
103
|
+
background: #f0f4ff;
|
|
104
|
+
border-color: #c5d3f7;
|
|
105
|
+
color: #2c4bad;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
[data-fz-zone="columns"] .fz-chip {
|
|
109
|
+
background: #f0fdf4;
|
|
110
|
+
border-color: #a7d7b8;
|
|
111
|
+
color: #1a6638;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.fz-chip--dragging {
|
|
115
|
+
opacity: 0.35;
|
|
116
|
+
cursor: grabbing;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.fz-chip--ghost {
|
|
120
|
+
cursor: grabbing;
|
|
121
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.fz-chip-remove {
|
|
125
|
+
font-size: 13px;
|
|
126
|
+
line-height: 1;
|
|
127
|
+
color: #999;
|
|
128
|
+
cursor: pointer;
|
|
129
|
+
padding: 0 1px;
|
|
130
|
+
border-radius: 2px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.fz-chip-remove:hover {
|
|
134
|
+
color: #e53935;
|
|
135
|
+
background: #fde;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.fz-chip--filtered {
|
|
139
|
+
border-color: #6172f3;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.fz-chip-hint {
|
|
143
|
+
font-size: 10px;
|
|
144
|
+
font-weight: 600;
|
|
145
|
+
color: #6172f3;
|
|
146
|
+
background: #eff4ff;
|
|
147
|
+
border-radius: 4px;
|
|
148
|
+
padding: 0 4px;
|
|
149
|
+
margin-left: 2px;
|
|
150
|
+
pointer-events: none;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.fz-tooltip {
|
|
154
|
+
display: none;
|
|
155
|
+
position: fixed;
|
|
156
|
+
z-index: 10000;
|
|
157
|
+
background: #fff;
|
|
158
|
+
color: #344054;
|
|
159
|
+
font-size: 12px;
|
|
160
|
+
padding: 6px 10px;
|
|
161
|
+
border: 1px solid #d0d5dd;
|
|
162
|
+
border-radius: 6px;
|
|
163
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
164
|
+
white-space: pre-wrap;
|
|
165
|
+
max-width: 260px;
|
|
166
|
+
pointer-events: none;
|
|
167
|
+
}
|