@svgrid/enterprise 2.0.3 → 2.2.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/README.md +18 -6
- package/dist/cdn/svgrid-enterprise.svelte-external.js +14025 -6836
- package/dist/node/studio.js +7889 -2460
- package/package.json +9 -4
- package/src/SvGridMasterDetail.svelte +24 -3
- package/src/SvGridScheduler.svelte +4410 -0
- package/src/SvPivotDesigner.svelte +1990 -1045
- package/src/SvSchemaChart.svelte +10 -9
- package/src/ai.test.ts +522 -522
- package/src/ai.ts +202 -2
- package/src/index.ts +409 -384
- package/src/install.ts +10 -0
- package/src/pivot-chart.test.ts +86 -0
- package/src/pivot-chart.ts +112 -0
- package/src/scheduler.ts +37 -0
- package/src/scheduling.test.ts +194 -0
- package/src/scheduling.ts +293 -0
- package/src/sources/filters.ts +6 -0
- package/src/studio/HANDLERS-DESIGN.md +142 -0
- package/src/studio/cli.ts +7 -2
- package/src/studio/emit-project.test.ts +1447 -13
- package/src/studio/emit-project.ts +3995 -1273
- package/src/studio/emit-schema.ts +146 -29
- package/src/studio/index.ts +320 -195
- package/src/studio/project.test.ts +370 -0
- package/src/studio/project.ts +1146 -26
- package/src/studio/sample-data.ts +4 -1
- package/src/studio/samples/ats.ts +2 -2
- package/src/studio/samples/clinic.ts +4 -2
- package/src/studio/samples/crm.ts +16 -8
- package/src/studio/samples/events.ts +4 -2
- package/src/studio/samples/fleet.ts +4 -2
- package/src/studio/samples/gym.ts +4 -2
- package/src/studio/samples/hr.ts +3 -1
- package/src/studio/samples/live-data.ts +308 -308
- package/src/studio/samples/projects.ts +2 -2
- package/src/studio/samples/restaurant.ts +4 -2
- package/src/studio/samples/samples.test.ts +13 -5
- package/src/studio/samples/shared.ts +346 -305
- package/src/studio/samples/support.ts +3 -1
- package/src/studio/scaffold.test.ts +15 -1
- package/src/studio/scaffold.ts +16 -0
- package/src/studio/themes.ts +7 -0
- package/src/studio/ui-components.ts +472 -0
- package/src/sveltekit/transport.test.ts +26 -0
- package/src/sveltekit/transport.ts +50 -5
- package/dist/designer/assets/index-Dp44bTid.js +0 -939
- package/dist/designer/assets/index-RJp6x8tw.css +0 -1
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
- package/dist/designer/index.html +0 -13
|
@@ -1,1045 +1,1990 @@
|
|
|
1
|
-
<script lang="ts" module>
|
|
2
|
-
type PivotInputRow = Record<string, unknown>
|
|
3
|
-
</script>
|
|
4
|
-
<script lang="ts" generics="T extends PivotInputRow">
|
|
5
|
-
/**
|
|
6
|
-
* SvPivotDesigner - Excel-style pivot table designer.
|
|
7
|
-
*
|
|
8
|
-
* Self-contained, controlled component. The consumer holds the data
|
|
9
|
-
* + the field list + the layout state; everything else (drag-and-drop,
|
|
10
|
-
* chip menus, search, presets, the inline pivot grid) is built in.
|
|
11
|
-
*
|
|
12
|
-
* Minimal wire-up (in a .svelte file):
|
|
13
|
-
* const fields = [
|
|
14
|
-
* { field: 'region', label: 'Region', kind: 'dimension' },
|
|
15
|
-
* { field: 'quarter', label: 'Quarter', kind: 'dimension' },
|
|
16
|
-
* { field: 'amount', label: 'Revenue', kind: 'measure', defaultAgg: 'sum' },
|
|
17
|
-
* ]
|
|
18
|
-
* let layout = $state(defaultLayoutFor(fields))
|
|
19
|
-
*
|
|
20
|
-
* SvPivotDesigner {data} {fields} bind:layout
|
|
21
|
-
*/
|
|
22
|
-
import {
|
|
23
|
-
SvGrid,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
emit({
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
let
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
function
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
{
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
{
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
</
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
type PivotInputRow = Record<string, unknown>
|
|
3
|
+
</script>
|
|
4
|
+
<script lang="ts" generics="T extends PivotInputRow">
|
|
5
|
+
/**
|
|
6
|
+
* SvPivotDesigner - Excel-style pivot table designer.
|
|
7
|
+
*
|
|
8
|
+
* Self-contained, controlled component. The consumer holds the data
|
|
9
|
+
* + the field list + the layout state; everything else (drag-and-drop,
|
|
10
|
+
* chip menus, search, presets, the inline pivot grid) is built in.
|
|
11
|
+
*
|
|
12
|
+
* Minimal wire-up (in a .svelte file):
|
|
13
|
+
* const fields = [
|
|
14
|
+
* { field: 'region', label: 'Region', kind: 'dimension' },
|
|
15
|
+
* { field: 'quarter', label: 'Quarter', kind: 'dimension' },
|
|
16
|
+
* { field: 'amount', label: 'Revenue', kind: 'measure', defaultAgg: 'sum' },
|
|
17
|
+
* ]
|
|
18
|
+
* let layout = $state(defaultLayoutFor(fields))
|
|
19
|
+
*
|
|
20
|
+
* SvPivotDesigner {data} {fields} bind:layout
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
SvGrid,
|
|
24
|
+
SvGridChart,
|
|
25
|
+
SvMenuList,
|
|
26
|
+
tableFeatures,
|
|
27
|
+
rowSortingFeature,
|
|
28
|
+
columnFilteringFeature,
|
|
29
|
+
renderSnippet,
|
|
30
|
+
portalToBody,
|
|
31
|
+
popIn,
|
|
32
|
+
createDismissableLayer,
|
|
33
|
+
createFocusTrap,
|
|
34
|
+
type ColumnDef,
|
|
35
|
+
type ChartType,
|
|
36
|
+
type MenuItem,
|
|
37
|
+
} from '@svgrid/grid'
|
|
38
|
+
import { createPivotModel, filterCollapsedPivotRows, type PivotRow, type PivotAggregatorId } from './pivot'
|
|
39
|
+
import { pivotToChartSpec } from './pivot-chart'
|
|
40
|
+
import {
|
|
41
|
+
ALL_AGGREGATORS, AGG_LABEL, EMPTY_LAYOUT, defaultLayoutFor,
|
|
42
|
+
type PivotField, type PivotLayout, type PivotPreset, type Well,
|
|
43
|
+
} from './pivot-designer'
|
|
44
|
+
|
|
45
|
+
type Props = {
|
|
46
|
+
/** Flat input rows. */
|
|
47
|
+
data: T[]
|
|
48
|
+
/** All fields the user can pick from the rail. */
|
|
49
|
+
fields: PivotField<T>[]
|
|
50
|
+
/** The current pivot layout. Bindable so the consumer can persist it. */
|
|
51
|
+
layout?: PivotLayout
|
|
52
|
+
/** Fired when the user changes the layout (drag, drop, chip menu, …). */
|
|
53
|
+
onLayoutChange?: (layout: PivotLayout) => void
|
|
54
|
+
|
|
55
|
+
// ---- Optional features --------------------------------------------
|
|
56
|
+
/** Saved layouts surfaced in the toolbar's Presets menu. */
|
|
57
|
+
presets?: PivotPreset[]
|
|
58
|
+
/** Aggregators offered in the Values chip menu. Default: all. */
|
|
59
|
+
aggregators?: PivotAggregatorId[]
|
|
60
|
+
/** Show the toolbar above the wells. Default true. */
|
|
61
|
+
showToolbar?: boolean
|
|
62
|
+
/** Show the left-rail field picker. Default true. */
|
|
63
|
+
showFieldList?: boolean
|
|
64
|
+
/** Show the Filters well. Default true. */
|
|
65
|
+
showFiltersWell?: boolean
|
|
66
|
+
/** Custom Export handler. When set, an Export button appears. */
|
|
67
|
+
onExport?: (layout: PivotLayout, rows: PivotRow[]) => void
|
|
68
|
+
/** Height of the inner pivot grid. Default '100%'. */
|
|
69
|
+
gridHeight?: string | number
|
|
70
|
+
/** Render the embedded grid? Set false to host it separately and
|
|
71
|
+
* read `pivot` from the on:pivot event. Default true. */
|
|
72
|
+
embedGrid?: boolean
|
|
73
|
+
/** Fires whenever the underlying pivot model rebuilds. */
|
|
74
|
+
onPivot?: (rows: PivotRow[], columns: ColumnDef<typeof features, PivotRow>[]) => void
|
|
75
|
+
/** Allow row-level expand / collapse. When true, an expand chevron
|
|
76
|
+
* appears in the label cell on every `group` row and clicking it
|
|
77
|
+
* toggles which descendant rows are visible. The designer manages
|
|
78
|
+
* the collapsed set internally. Default false. */
|
|
79
|
+
expandable?: boolean
|
|
80
|
+
/** Transform the generated column tree right before the grid renders.
|
|
81
|
+
* The consumer can attach custom `cell:` / `header:` / `cellClass:`
|
|
82
|
+
* properties, change widths, etc. Receives the full tree + the
|
|
83
|
+
* current layout (so it can branch on which measures are present). */
|
|
84
|
+
decorateColumns?: (
|
|
85
|
+
cols: ColumnDef<typeof features, PivotRow>[],
|
|
86
|
+
layout: PivotLayout,
|
|
87
|
+
) => ColumnDef<typeof features, PivotRow>[]
|
|
88
|
+
/** Click handler forwarded to the embedded SvGrid - typically used
|
|
89
|
+
* to drive a drill-through side panel. */
|
|
90
|
+
onCellClick?: (ctx: { columnId: string; row: PivotRow; value: unknown }) => void
|
|
91
|
+
/** Offer a Table <-> Chart view toggle (same layout as a live chart). Default true. */
|
|
92
|
+
chartable?: boolean
|
|
93
|
+
/** Which view to show first when `chartable`. Default 'table'. */
|
|
94
|
+
defaultView?: 'table' | 'chart'
|
|
95
|
+
|
|
96
|
+
// ---- Presentation -------------------------------------------------
|
|
97
|
+
/**
|
|
98
|
+
* Where the authoring UI (field picker + wells) lives relative to the
|
|
99
|
+
* grid. `'top'` (default) keeps the classic Excel layout: left rail +
|
|
100
|
+
* a horizontal row of wells above the grid. `'right'` docks everything
|
|
101
|
+
* as a single vertical tool panel on the right of the grid - the
|
|
102
|
+
* "enterprise data grid" / AG-Grid pivot-panel arrangement.
|
|
103
|
+
*/
|
|
104
|
+
panelPosition?: 'top' | 'right'
|
|
105
|
+
/** Width of the docked panel when `panelPosition='right'`. Default 280. */
|
|
106
|
+
panelWidth?: number | string
|
|
107
|
+
/**
|
|
108
|
+
* Enables a "Pivot Mode" toggle. When OFF the embedded grid renders the
|
|
109
|
+
* flat source rows with `flatColumns`; when ON it renders the pivot.
|
|
110
|
+
* Requires `flatColumns` to be provided. Bindable.
|
|
111
|
+
*/
|
|
112
|
+
pivotMode?: boolean
|
|
113
|
+
/** Columns for the flat (pivot-off) grid. Enables the Pivot Mode toggle. */
|
|
114
|
+
flatColumns?: ColumnDef<typeof features, T>[]
|
|
115
|
+
/** Fired when the Pivot Mode toggle changes. */
|
|
116
|
+
onPivotModeChange?: (on: boolean) => void
|
|
117
|
+
/** Fit the embedded grid's columns to width. Default true. Set false to
|
|
118
|
+
* let a wide pivot scroll horizontally (recommended with many columns). */
|
|
119
|
+
gridFitColumns?: boolean
|
|
120
|
+
/** Enable the right-click context menu on field-list rows, well chips, and
|
|
121
|
+
* the pivot grid (add/move/remove fields, change aggregator, expand /
|
|
122
|
+
* collapse). Default true. */
|
|
123
|
+
contextMenu?: boolean
|
|
124
|
+
/**
|
|
125
|
+
* Column virtualization on the embedded grid. Default true. Set FALSE for
|
|
126
|
+
* wide pivots with grouped column headers: the grouped header band isn't
|
|
127
|
+
* virtualized, so a virtualized body leaves off-window group columns
|
|
128
|
+
* looking empty until scrolled. Disabling it renders every column, so
|
|
129
|
+
* header and body always stay in sync (fine up to a few hundred columns).
|
|
130
|
+
*/
|
|
131
|
+
columnVirtualization?: boolean
|
|
132
|
+
/**
|
|
133
|
+
* Render the field picker as an AG-Grid-style **Columns** tool panel: a
|
|
134
|
+
* collapsible column-group TREE whose checkboxes toggle column VISIBILITY
|
|
135
|
+
* (with a select-all), instead of the flat "tick to add to a well" list.
|
|
136
|
+
* Fields still drag into the Rows / Columns / Values wells. The embedded
|
|
137
|
+
* flat grid (pivot off) hides deselected columns. Default false.
|
|
138
|
+
*/
|
|
139
|
+
columnTree?: boolean
|
|
140
|
+
/** Hidden column ids (field names) when `columnTree` is on. Bindable. */
|
|
141
|
+
hiddenFields?: string[]
|
|
142
|
+
/** Fired when column visibility changes (columnTree mode). */
|
|
143
|
+
onHiddenFieldsChange?: (hidden: string[]) => void
|
|
144
|
+
/**
|
|
145
|
+
* Show a vertical Columns / Filters tab rail on the docked panel
|
|
146
|
+
* (`panelPosition='right'`). The Filters tab is an AG-Grid-style set-filter
|
|
147
|
+
* builder: "Add Filter" -> pick a column -> tick which values pass. Filters
|
|
148
|
+
* narrow the source rows (they drive `layout.filters`). Default false.
|
|
149
|
+
*/
|
|
150
|
+
toolTabs?: boolean
|
|
151
|
+
}
|
|
152
|
+
let {
|
|
153
|
+
data,
|
|
154
|
+
fields,
|
|
155
|
+
layout = $bindable<PivotLayout>(defaultLayoutFor([])),
|
|
156
|
+
onLayoutChange,
|
|
157
|
+
presets,
|
|
158
|
+
aggregators = ALL_AGGREGATORS,
|
|
159
|
+
showToolbar = true,
|
|
160
|
+
showFieldList = true,
|
|
161
|
+
showFiltersWell = true,
|
|
162
|
+
onExport,
|
|
163
|
+
gridHeight = '100%',
|
|
164
|
+
embedGrid = true,
|
|
165
|
+
onPivot,
|
|
166
|
+
expandable = false,
|
|
167
|
+
decorateColumns,
|
|
168
|
+
onCellClick,
|
|
169
|
+
chartable = true,
|
|
170
|
+
defaultView = 'table',
|
|
171
|
+
panelPosition = 'top',
|
|
172
|
+
panelWidth = 280,
|
|
173
|
+
pivotMode = $bindable(true),
|
|
174
|
+
flatColumns,
|
|
175
|
+
onPivotModeChange,
|
|
176
|
+
gridFitColumns = true,
|
|
177
|
+
columnVirtualization = true,
|
|
178
|
+
contextMenu = true,
|
|
179
|
+
columnTree = false,
|
|
180
|
+
hiddenFields = $bindable<string[]>([]),
|
|
181
|
+
onHiddenFieldsChange,
|
|
182
|
+
toolTabs = false,
|
|
183
|
+
}: Props = $props()
|
|
184
|
+
|
|
185
|
+
// ---- Columns / Filters tab rail ----------------------------------
|
|
186
|
+
let activeTab = $state<'columns' | 'filters'>('columns')
|
|
187
|
+
let filterCollapsed = $state<Set<string>>(new Set())
|
|
188
|
+
let filterSearch = $state<Record<string, string>>({})
|
|
189
|
+
let addFilterOpen = $state(false)
|
|
190
|
+
let addFilterSearch = $state('')
|
|
191
|
+
function toggleFilterCard(field: string) {
|
|
192
|
+
const next = new Set(filterCollapsed)
|
|
193
|
+
if (next.has(field)) next.delete(field); else next.add(field)
|
|
194
|
+
filterCollapsed = next
|
|
195
|
+
}
|
|
196
|
+
function toggleFilterValue(f: { field: string; allowed: string[] | null }, v: string, all: string[]) {
|
|
197
|
+
const cur = f.allowed ?? all.slice()
|
|
198
|
+
const next = cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v]
|
|
199
|
+
setFilterAllowed(f.field, next.length === all.length ? null : next)
|
|
200
|
+
}
|
|
201
|
+
function addFilterField(field: string) {
|
|
202
|
+
if (!layout.filters.some((f) => f.field === field)) {
|
|
203
|
+
emit({ ...layout, filters: [...layout.filters, { field, allowed: null }] })
|
|
204
|
+
}
|
|
205
|
+
const nc = new Set(filterCollapsed); nc.delete(field); filterCollapsed = nc
|
|
206
|
+
addFilterOpen = false
|
|
207
|
+
addFilterSearch = ''
|
|
208
|
+
}
|
|
209
|
+
const availableFilterFields = $derived.by(() => {
|
|
210
|
+
const used = new Set(layout.filters.map((f) => f.field))
|
|
211
|
+
const q = addFilterSearch.trim().toLowerCase()
|
|
212
|
+
return fields.filter((f) => !used.has(f.field) && (!q || f.label.toLowerCase().includes(q)))
|
|
213
|
+
})
|
|
214
|
+
// Close the Add-Filter column picker on outside click.
|
|
215
|
+
$effect(() => {
|
|
216
|
+
if (!addFilterOpen) return
|
|
217
|
+
function on(e: MouseEvent) {
|
|
218
|
+
const t = e.target as HTMLElement | null
|
|
219
|
+
if (t && t.closest(`[data-pvd-addfilter="${uid}"]`)) return
|
|
220
|
+
addFilterOpen = false
|
|
221
|
+
}
|
|
222
|
+
window.addEventListener('mousedown', on)
|
|
223
|
+
return () => window.removeEventListener('mousedown', on)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const showPivotToggle = $derived(!!flatColumns)
|
|
227
|
+
function setPivotMode(on: boolean) {
|
|
228
|
+
pivotMode = on
|
|
229
|
+
onPivotModeChange?.(on)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Default the layout once on mount if the consumer passed nothing
|
|
233
|
+
// meaningful. We seed inside $effect.pre so $bindable picks up the
|
|
234
|
+
// mutation BEFORE the first render reads it.
|
|
235
|
+
$effect.pre(() => {
|
|
236
|
+
if (!layout || (!layout.rows.length && !layout.cols.length && !layout.values.length && !layout.filters.length)) {
|
|
237
|
+
layout = defaultLayoutFor(fields)
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
|
|
242
|
+
const uid = `pvd-${Math.random().toString(36).slice(2, 8)}`
|
|
243
|
+
|
|
244
|
+
// Lookup helpers -----------------------------------------------------
|
|
245
|
+
const fieldsByName = $derived(new Map(fields.map((f) => [f.field, f])))
|
|
246
|
+
/** Field is currently "in use" - on any well - so the picker can grey it out. */
|
|
247
|
+
function isFieldInLayout(field: string): boolean {
|
|
248
|
+
return layout.rows.includes(field) ||
|
|
249
|
+
layout.cols.includes(field) ||
|
|
250
|
+
layout.values.some((v) => v.field === field) ||
|
|
251
|
+
layout.filters.some((f) => f.field === field)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- Search + grouped picker --------------------------------------
|
|
255
|
+
let search = $state('')
|
|
256
|
+
const filteredFields = $derived(
|
|
257
|
+
fields.filter((f) => !search.trim() || f.label.toLowerCase().includes(search.trim().toLowerCase())),
|
|
258
|
+
)
|
|
259
|
+
const groupedFields = $derived.by(() => {
|
|
260
|
+
const groups = new Map<string, PivotField<T>[]>()
|
|
261
|
+
for (const f of filteredFields) {
|
|
262
|
+
const key = f.group ?? (f.kind === 'dimension' ? 'Dimensions' : 'Measures')
|
|
263
|
+
const arr = groups.get(key) ?? []
|
|
264
|
+
arr.push(f); groups.set(key, arr)
|
|
265
|
+
}
|
|
266
|
+
return [...groups.entries()]
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// ---- Columns tool panel: visibility tree (columnTree mode) --------
|
|
270
|
+
const hiddenSet = $derived(new Set(hiddenFields))
|
|
271
|
+
const isFieldVisible = (field: string) => !hiddenSet.has(field)
|
|
272
|
+
let collapsedGroups = $state<Set<string>>(new Set())
|
|
273
|
+
function toggleGroupCollapsed(group: string) {
|
|
274
|
+
const next = new Set(collapsedGroups)
|
|
275
|
+
if (next.has(group)) next.delete(group); else next.add(group)
|
|
276
|
+
collapsedGroups = next
|
|
277
|
+
}
|
|
278
|
+
function emitHidden(next: Set<string>) {
|
|
279
|
+
const arr = [...next]
|
|
280
|
+
hiddenFields = arr
|
|
281
|
+
onHiddenFieldsChange?.(arr)
|
|
282
|
+
}
|
|
283
|
+
function setFieldVisible(field: string, visible: boolean) {
|
|
284
|
+
const next = new Set(hiddenSet)
|
|
285
|
+
if (visible) next.delete(field); else next.add(field)
|
|
286
|
+
emitHidden(next)
|
|
287
|
+
}
|
|
288
|
+
/** Every field id (across all groups), regardless of the search filter. */
|
|
289
|
+
const allFieldIds = $derived(fields.map((f) => f.field))
|
|
290
|
+
/** Visibility of a whole group: 'all' | 'some' | 'none'. */
|
|
291
|
+
function groupVisibility(items: PivotField<T>[]): 'all' | 'some' | 'none' {
|
|
292
|
+
const vis = items.filter((f) => isFieldVisible(f.field)).length
|
|
293
|
+
return vis === 0 ? 'none' : vis === items.length ? 'all' : 'some'
|
|
294
|
+
}
|
|
295
|
+
function toggleGroupVisible(items: PivotField<T>[]) {
|
|
296
|
+
const makeVisible = groupVisibility(items) !== 'all'
|
|
297
|
+
const next = new Set(hiddenSet)
|
|
298
|
+
for (const f of items) { if (makeVisible) next.delete(f.field); else next.add(f.field) }
|
|
299
|
+
emitHidden(next)
|
|
300
|
+
}
|
|
301
|
+
const allVisibility = $derived.by<'all' | 'some' | 'none'>(() => {
|
|
302
|
+
const vis = allFieldIds.filter((id) => isFieldVisible(id)).length
|
|
303
|
+
return vis === 0 ? 'none' : vis === allFieldIds.length ? 'all' : 'some'
|
|
304
|
+
})
|
|
305
|
+
function toggleAllVisible() {
|
|
306
|
+
const makeVisible = allVisibility !== 'all'
|
|
307
|
+
emitHidden(makeVisible ? new Set<string>() : new Set(allFieldIds))
|
|
308
|
+
}
|
|
309
|
+
/** `flatColumns` with hidden leaves removed (and now-empty groups dropped). */
|
|
310
|
+
const visibleFlatColumns = $derived.by(() => {
|
|
311
|
+
if (!flatColumns || !columnTree || hiddenSet.size === 0) return flatColumns
|
|
312
|
+
type Col = ColumnDef<typeof features, T>
|
|
313
|
+
const filter = (cols: Col[]): Col[] => {
|
|
314
|
+
const out: Col[] = []
|
|
315
|
+
for (const c of cols) {
|
|
316
|
+
const kids = (c as { columns?: Col[] }).columns
|
|
317
|
+
if (kids?.length) {
|
|
318
|
+
const fk = filter(kids)
|
|
319
|
+
if (fk.length) out.push({ ...c, columns: fk } as Col)
|
|
320
|
+
} else {
|
|
321
|
+
const id = ((c as { field?: string }).field ?? c.id) as string | undefined
|
|
322
|
+
if (!id || !hiddenSet.has(id)) out.push(c)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return out
|
|
326
|
+
}
|
|
327
|
+
return filter(flatColumns)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
// ---- Mutation helpers ---------------------------------------------
|
|
331
|
+
function emit(next: PivotLayout) {
|
|
332
|
+
layout = next
|
|
333
|
+
onLayoutChange?.(next)
|
|
334
|
+
}
|
|
335
|
+
function defaultWellFor(field: PivotField<T>): Well {
|
|
336
|
+
return field.kind === 'measure' ? 'values' : 'rows'
|
|
337
|
+
}
|
|
338
|
+
/** Add a field to a well at `index` (or end). For rows / cols / filters a
|
|
339
|
+
* field can appear at most ONCE - dragging it in moves it. For values
|
|
340
|
+
* the same field with the SAME aggregator can only appear once, but
|
|
341
|
+
* the same field with DIFFERENT aggregators is valid (e.g. spend/sum
|
|
342
|
+
* + spend/avg in a scorecard). `sourceIndex` is the chip's position
|
|
343
|
+
* in `values` when dragging within that well, used to dedup the move. */
|
|
344
|
+
function addToWell(field: string, well: Well, index = Infinity, sourceIndex?: number) {
|
|
345
|
+
const f = fieldsByName.get(field)
|
|
346
|
+
if (!f) return
|
|
347
|
+
const next: PivotLayout = {
|
|
348
|
+
...layout,
|
|
349
|
+
rows: layout.rows.filter((x) => x !== field),
|
|
350
|
+
cols: layout.cols.filter((x) => x !== field),
|
|
351
|
+
// For values: only strip if we're MOVING a specific chip out of the
|
|
352
|
+
// well. Don't strip every chip with the same field (that broke
|
|
353
|
+
// "spend/sum + spend/avg" presets with a duplicate-key error).
|
|
354
|
+
values: sourceIndex !== undefined
|
|
355
|
+
? layout.values.filter((_, i) => i !== sourceIndex)
|
|
356
|
+
: layout.values.slice(),
|
|
357
|
+
filters: layout.filters.filter((v) => v.field !== field),
|
|
358
|
+
}
|
|
359
|
+
if (well === 'rows' || well === 'cols') {
|
|
360
|
+
const arr = next[well]
|
|
361
|
+
arr.splice(Math.min(arr.length, index), 0, field)
|
|
362
|
+
} else if (well === 'values') {
|
|
363
|
+
const agg = f.defaultAgg ?? 'sum' as PivotAggregatorId
|
|
364
|
+
// Skip if (field, agg) pair already present after the strip.
|
|
365
|
+
if (next.values.some((v) => v.field === field && v.agg === agg)) {
|
|
366
|
+
emit(next); return
|
|
367
|
+
}
|
|
368
|
+
const chip = { field, agg, label: f.label, format: f.format }
|
|
369
|
+
next.values.splice(Math.min(next.values.length, index), 0, chip)
|
|
370
|
+
} else if (well === 'filters') {
|
|
371
|
+
next.filters.splice(Math.min(next.filters.length, index), 0, { field, allowed: null })
|
|
372
|
+
}
|
|
373
|
+
emit(next)
|
|
374
|
+
// A filter added with `allowed: null` passes every row, so dropping a field
|
|
375
|
+
// into Filters changes nothing until you pick values - auto-open the picker
|
|
376
|
+
// so the drop has an immediate, obvious effect.
|
|
377
|
+
if (well === 'filters') openMenu = { kind: 'filter', field }
|
|
378
|
+
}
|
|
379
|
+
function removeFromWell(field: string, well: Well, valueIndex?: number) {
|
|
380
|
+
const next: PivotLayout = { ...layout }
|
|
381
|
+
if (well === 'rows') next.rows = layout.rows.filter((x) => x !== field)
|
|
382
|
+
else if (well === 'cols') next.cols = layout.cols.filter((x) => x !== field)
|
|
383
|
+
else if (well === 'values') {
|
|
384
|
+
// Remove a SPECIFIC chip by its index, since the same field can
|
|
385
|
+
// appear multiple times with different aggregators.
|
|
386
|
+
next.values = valueIndex !== undefined
|
|
387
|
+
? layout.values.filter((_, i) => i !== valueIndex)
|
|
388
|
+
: layout.values.filter((v) => v.field !== field)
|
|
389
|
+
}
|
|
390
|
+
else if (well === 'filters') next.filters = layout.filters.filter((v) => v.field !== field)
|
|
391
|
+
emit(next)
|
|
392
|
+
}
|
|
393
|
+
function toggleFieldDefault(field: string) {
|
|
394
|
+
const f = fieldsByName.get(field); if (!f) return
|
|
395
|
+
if (isFieldInLayout(field)) {
|
|
396
|
+
// Remove from every well (all chips for this field, even multiple
|
|
397
|
+
// value chips with different aggregators).
|
|
398
|
+
emit({
|
|
399
|
+
...layout,
|
|
400
|
+
rows: layout.rows.filter((x) => x !== field),
|
|
401
|
+
cols: layout.cols.filter((x) => x !== field),
|
|
402
|
+
values: layout.values.filter((v) => v.field !== field),
|
|
403
|
+
filters: layout.filters.filter((v) => v.field !== field),
|
|
404
|
+
})
|
|
405
|
+
} else {
|
|
406
|
+
addToWell(field, defaultWellFor(f))
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** Update the aggregator of the chip at the given index. If the change
|
|
410
|
+
* would produce a duplicate (field, agg) pair, the chip is removed
|
|
411
|
+
* instead of creating a key collision. */
|
|
412
|
+
function setAggregatorAt(index: number, agg: PivotAggregatorId) {
|
|
413
|
+
const chip = layout.values[index]
|
|
414
|
+
if (!chip) return
|
|
415
|
+
const field = chip.field
|
|
416
|
+
const dup = layout.values.some((v, i) => i !== index && v.field === field && v.agg === agg)
|
|
417
|
+
const label = AGG_LABEL[agg] + ' of ' + (fieldsByName.get(field)?.label ?? field)
|
|
418
|
+
emit({
|
|
419
|
+
...layout,
|
|
420
|
+
values: dup
|
|
421
|
+
? layout.values.filter((_, i) => i !== index)
|
|
422
|
+
: layout.values.map((v, i) => (i === index ? { ...v, agg, label } : v)),
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
function setFilterAllowed(field: string, allowed: string[] | null) {
|
|
426
|
+
emit({
|
|
427
|
+
...layout,
|
|
428
|
+
filters: layout.filters.map((f) => (f.field === field ? { ...f, allowed } : f)),
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
function toggleHideSubtotals() {
|
|
432
|
+
emit({ ...layout, hideSubtotals: !layout.hideSubtotals })
|
|
433
|
+
}
|
|
434
|
+
function toggleHideGrandTotals() {
|
|
435
|
+
emit({ ...layout, hideGrandTotals: !layout.hideGrandTotals })
|
|
436
|
+
}
|
|
437
|
+
function reset() {
|
|
438
|
+
emit(defaultLayoutFor(fields))
|
|
439
|
+
}
|
|
440
|
+
function loadPreset(p: PivotPreset) {
|
|
441
|
+
emit(structuredClone(p.layout) as PivotLayout)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---- Drag-and-drop ------------------------------------------------
|
|
445
|
+
/** What's being dragged: field id + the well it came from. `dragIndex`
|
|
446
|
+
* is the source position in the values well (only set when dragging a
|
|
447
|
+
* value chip; other wells have at most one chip per field so they
|
|
448
|
+
* don't need an index). */
|
|
449
|
+
let dragField = $state<string | null>(null)
|
|
450
|
+
let dragFrom = $state<Well | 'rail' | null>(null)
|
|
451
|
+
let dragIndex = $state<number | undefined>(undefined)
|
|
452
|
+
let dragOver = $state<Well | null>(null)
|
|
453
|
+
function onDragStart(e: DragEvent, field: string, from: Well | 'rail', index?: number) {
|
|
454
|
+
dragField = field
|
|
455
|
+
dragFrom = from
|
|
456
|
+
dragIndex = index
|
|
457
|
+
if (e.dataTransfer) {
|
|
458
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
459
|
+
e.dataTransfer.setData('text/plain', field)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function onDragOver(e: DragEvent, well: Well) {
|
|
463
|
+
e.preventDefault()
|
|
464
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
|
465
|
+
dragOver = well
|
|
466
|
+
}
|
|
467
|
+
function onDragLeave() { dragOver = null }
|
|
468
|
+
function onDrop(e: DragEvent, well: Well) {
|
|
469
|
+
e.preventDefault()
|
|
470
|
+
dragOver = null
|
|
471
|
+
const field = dragField || e.dataTransfer?.getData('text/plain') || ''
|
|
472
|
+
if (!field) return
|
|
473
|
+
// Pass the source index only when dragging FROM the values well, so
|
|
474
|
+
// a same-field move (e.g. reorder spend/avg) removes the right chip.
|
|
475
|
+
const srcIdx = dragFrom === 'values' ? dragIndex : undefined
|
|
476
|
+
addToWell(field, well, Infinity, srcIdx)
|
|
477
|
+
dragField = null
|
|
478
|
+
dragFrom = null
|
|
479
|
+
dragIndex = undefined
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---- Apply filters BEFORE pivot ------------------------------------
|
|
483
|
+
// The pivot model itself has no filtering, but the well clearly should:
|
|
484
|
+
// a Filters chip with `allowed` restricts which rows reach the model.
|
|
485
|
+
const filteredData = $derived.by(() => {
|
|
486
|
+
if (!layout.filters.length) return data
|
|
487
|
+
return data.filter((row) => {
|
|
488
|
+
for (const f of layout.filters) {
|
|
489
|
+
if (f.allowed == null) continue
|
|
490
|
+
const v = row[f.field]
|
|
491
|
+
if (!f.allowed.includes(String(v))) return false
|
|
492
|
+
}
|
|
493
|
+
return true
|
|
494
|
+
})
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
// ---- Build the pivot model ----------------------------------------
|
|
498
|
+
// Whenever the layout or data changes, re-run the pivot. Empty layouts
|
|
499
|
+
// are tolerated: the grid will show a friendly empty state.
|
|
500
|
+
const pivot = $derived.by(() => {
|
|
501
|
+
if (!layout.values.length && !layout.rows.length && !layout.cols.length) return null
|
|
502
|
+
if (!layout.values.length) return null
|
|
503
|
+
return createPivotModel(filteredData, {
|
|
504
|
+
rows: layout.rows as Array<keyof T & string>,
|
|
505
|
+
cols: layout.cols as Array<keyof T & string>,
|
|
506
|
+
values: layout.values.map((v) => ({
|
|
507
|
+
field: v.field as keyof T & string,
|
|
508
|
+
agg: v.agg,
|
|
509
|
+
label: v.label,
|
|
510
|
+
format: v.format,
|
|
511
|
+
})),
|
|
512
|
+
grandTotalRow: !layout.hideGrandTotals,
|
|
513
|
+
grandTotalCol: !layout.hideGrandTotals,
|
|
514
|
+
rowSubtotals: !layout.hideSubtotals,
|
|
515
|
+
})
|
|
516
|
+
})
|
|
517
|
+
$effect(() => {
|
|
518
|
+
if (pivot) onPivot?.(pivot.rows, pivot.columns)
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
// ---- Table <-> Chart view (the same layout as a live pivot chart) ----
|
|
522
|
+
let view = $state<'table' | 'chart'>(defaultView === 'chart' && chartable ? 'chart' : 'table')
|
|
523
|
+
let chartType = $state<ChartType>('bar')
|
|
524
|
+
let chartStacked = $state(false)
|
|
525
|
+
const CHART_TYPES: Array<{ value: ChartType; label: string }> = [
|
|
526
|
+
{ value: 'bar', label: 'Bar' },
|
|
527
|
+
{ value: 'line', label: 'Line' },
|
|
528
|
+
{ value: 'area', label: 'Area' },
|
|
529
|
+
{ value: 'pie', label: 'Pie' },
|
|
530
|
+
]
|
|
531
|
+
const chartSpec = $derived.by(() =>
|
|
532
|
+
pivot && chartable && view === 'chart'
|
|
533
|
+
? pivotToChartSpec(pivot, { type: chartType, stacked: chartStacked })
|
|
534
|
+
: null,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
// ---- Expand / collapse (when `expandable` is on) ------------------
|
|
538
|
+
// We hold a `collapsed` set of pivot row ids; descendants of a
|
|
539
|
+
// collapsed group are filtered out via the model helper. Reset to
|
|
540
|
+
// fully-expanded whenever the source pivot rebuilds with different
|
|
541
|
+
// group ids - otherwise stale ids would linger across layout changes.
|
|
542
|
+
let collapsed = $state<Set<string>>(new Set())
|
|
543
|
+
let lastPivotKey = $state('')
|
|
544
|
+
$effect(() => {
|
|
545
|
+
if (!pivot) return
|
|
546
|
+
const key = pivot.rows.map((r) => r.__pivotId).join('|')
|
|
547
|
+
if (key !== lastPivotKey) {
|
|
548
|
+
lastPivotKey = key
|
|
549
|
+
// Keep ids still present, drop the rest.
|
|
550
|
+
const present = new Set(pivot.rows.map((r) => r.__pivotId))
|
|
551
|
+
const next = new Set<string>()
|
|
552
|
+
for (const id of collapsed) if (present.has(id)) next.add(id)
|
|
553
|
+
if (next.size !== collapsed.size) collapsed = next
|
|
554
|
+
}
|
|
555
|
+
})
|
|
556
|
+
function toggleRow(id: string) {
|
|
557
|
+
const next = new Set(collapsed)
|
|
558
|
+
if (next.has(id)) next.delete(id); else next.add(id)
|
|
559
|
+
collapsed = next
|
|
560
|
+
}
|
|
561
|
+
function expandAll() { collapsed = new Set() }
|
|
562
|
+
function collapseAll() {
|
|
563
|
+
if (!pivot) return
|
|
564
|
+
const next = new Set<string>()
|
|
565
|
+
for (const r of pivot.rows) if (r.__pivotExpandable) next.add(r.__pivotId)
|
|
566
|
+
collapsed = next
|
|
567
|
+
}
|
|
568
|
+
/** Rows the grid should actually render, honouring the collapsed set. */
|
|
569
|
+
const visibleRows = $derived.by(() => {
|
|
570
|
+
if (!pivot) return [] as PivotRow[]
|
|
571
|
+
if (!expandable || collapsed.size === 0) return pivot.rows
|
|
572
|
+
const expanded = new Set<string>()
|
|
573
|
+
for (const r of pivot.rows) {
|
|
574
|
+
if (r.__pivotExpandable && !collapsed.has(r.__pivotId)) expanded.add(r.__pivotId)
|
|
575
|
+
}
|
|
576
|
+
return filterCollapsedPivotRows(pivot.rows, expanded)
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
// ---- Column tree with optional decoration -------------------------
|
|
580
|
+
// When `expandable`, the first (label) column gets a built-in chevron
|
|
581
|
+
// renderer. The consumer's `decorateColumns` runs LAST so it can
|
|
582
|
+
// override even the chevron behaviour if it wants something custom.
|
|
583
|
+
const finalColumns = $derived.by(() => {
|
|
584
|
+
if (!pivot) return [] as ColumnDef<typeof features, PivotRow>[]
|
|
585
|
+
let cols = pivot.columns
|
|
586
|
+
// The engine builds the row-header column header from raw field names
|
|
587
|
+
// (e.g. "region / country") which renders lowercase. Replace it with
|
|
588
|
+
// the chained `field.label` from the picker so the rendered header
|
|
589
|
+
// matches the chip text in the wells (e.g. "Region / Country").
|
|
590
|
+
if (cols.length && layout.rows.length) {
|
|
591
|
+
const rowLabel = layout.rows
|
|
592
|
+
.map((f) => fieldsByName.get(f)?.label ?? f)
|
|
593
|
+
.join(' / ')
|
|
594
|
+
cols = [{ ...cols[0]!, header: rowLabel }, ...cols.slice(1)]
|
|
595
|
+
}
|
|
596
|
+
if (expandable) {
|
|
597
|
+
cols = cols.map((c, i) => {
|
|
598
|
+
if (i !== 0) return c
|
|
599
|
+
return {
|
|
600
|
+
...c,
|
|
601
|
+
cell: (ctx) => renderSnippet(ChevronLabelCell, {
|
|
602
|
+
row: ctx.row.original,
|
|
603
|
+
collapsed: collapsed.has(ctx.row.original.__pivotId),
|
|
604
|
+
onToggle: () => toggleRow(ctx.row.original.__pivotId),
|
|
605
|
+
}),
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
return decorateColumns ? decorateColumns(cols, layout) : cols
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
// ---- Filter chip menu (which values pass) --------------------------
|
|
613
|
+
/** Distinct values for a field, computed from the FULL data (so the
|
|
614
|
+
* menu shows every option, not just those the current filters
|
|
615
|
+
* already pass). */
|
|
616
|
+
function distinctValuesFor(field: string): string[] {
|
|
617
|
+
const set = new Set<string>()
|
|
618
|
+
for (const row of data) set.add(String(row[field]))
|
|
619
|
+
return [...set].sort((a, b) => a.localeCompare(b))
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ---- Active menus (open one chip at a time) ----------------------
|
|
623
|
+
// Filter menus key by `field` (one filter chip per field).
|
|
624
|
+
// Agg menus key by `index` (a field can have multiple value chips at
|
|
625
|
+
// different aggregators; index is the only stable identity).
|
|
626
|
+
let openMenu = $state<
|
|
627
|
+
| { kind: 'filter'; field: string }
|
|
628
|
+
| { kind: 'agg'; index: number }
|
|
629
|
+
| null
|
|
630
|
+
>(null)
|
|
631
|
+
function toggleFilterMenu(field: string) {
|
|
632
|
+
if (openMenu?.kind === 'filter' && openMenu.field === field) openMenu = null
|
|
633
|
+
else openMenu = { kind: 'filter', field }
|
|
634
|
+
}
|
|
635
|
+
function toggleAggMenu(index: number) {
|
|
636
|
+
if (openMenu?.kind === 'agg' && openMenu.index === index) openMenu = null
|
|
637
|
+
else openMenu = { kind: 'agg', index }
|
|
638
|
+
}
|
|
639
|
+
function closeMenu() { openMenu = null }
|
|
640
|
+
// Close on outside click.
|
|
641
|
+
$effect(() => {
|
|
642
|
+
if (!openMenu) return
|
|
643
|
+
function on(e: MouseEvent) {
|
|
644
|
+
const t = e.target as HTMLElement | null
|
|
645
|
+
if (t && t.closest(`[data-pvd-menu="${uid}"]`)) return
|
|
646
|
+
openMenu = null
|
|
647
|
+
}
|
|
648
|
+
window.addEventListener('mousedown', on)
|
|
649
|
+
return () => window.removeEventListener('mousedown', on)
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
// ---- Presets menu ------------------------------------------------
|
|
653
|
+
let presetsOpen = $state(false)
|
|
654
|
+
function togglePresets() { presetsOpen = !presetsOpen }
|
|
655
|
+
$effect(() => {
|
|
656
|
+
if (!presetsOpen) return
|
|
657
|
+
function on(e: MouseEvent) {
|
|
658
|
+
const t = e.target as HTMLElement | null
|
|
659
|
+
if (t && t.closest(`[data-pvd-presets="${uid}"]`)) return
|
|
660
|
+
presetsOpen = false
|
|
661
|
+
}
|
|
662
|
+
window.addEventListener('mousedown', on)
|
|
663
|
+
return () => window.removeEventListener('mousedown', on)
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
// ---- Right-click context menu ------------------------------------
|
|
667
|
+
// A single portalled SvMenuList whose items are computed from whatever
|
|
668
|
+
// was right-clicked: a field-list row, a well chip, or the pivot grid.
|
|
669
|
+
const WELL_LABEL: Record<Well, string> = { rows: 'Rows', cols: 'Columns', values: 'Values', filters: 'Filters' }
|
|
670
|
+
const KIND_WELLS: Record<'dimension' | 'measure', Well[]> = {
|
|
671
|
+
dimension: ['rows', 'cols', 'filters'],
|
|
672
|
+
measure: ['values', 'filters'],
|
|
673
|
+
}
|
|
674
|
+
let ctx = $state<{ x: number; y: number; items: MenuItem[] } | null>(null)
|
|
675
|
+
let ctxPanelEl = $state<HTMLDivElement | null>(null)
|
|
676
|
+
function openCtx(e: MouseEvent, items: MenuItem[]) {
|
|
677
|
+
if (!contextMenu || items.length === 0) return
|
|
678
|
+
e.preventDefault()
|
|
679
|
+
e.stopPropagation()
|
|
680
|
+
const W = 210
|
|
681
|
+
const estH = Math.min(items.length, 12) * 32 + 10
|
|
682
|
+
const x = Math.min(e.clientX, window.innerWidth - W - 6)
|
|
683
|
+
const y = Math.min(e.clientY, window.innerHeight - estH - 6)
|
|
684
|
+
ctx = { x: Math.max(6, x), y: Math.max(6, y), items }
|
|
685
|
+
}
|
|
686
|
+
function closeCtx() { ctx = null }
|
|
687
|
+
$effect(() => {
|
|
688
|
+
if (!ctx || !ctxPanelEl) return
|
|
689
|
+
const trap = createFocusTrap(ctxPanelEl, {
|
|
690
|
+
initialFocus: () => ctxPanelEl?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])') ?? null,
|
|
691
|
+
})
|
|
692
|
+
trap.activate()
|
|
693
|
+
const layer = createDismissableLayer({ element: () => ctxPanelEl, onDismiss: closeCtx })
|
|
694
|
+
layer.activate()
|
|
695
|
+
const onScroll = () => closeCtx()
|
|
696
|
+
window.addEventListener('scroll', onScroll, true)
|
|
697
|
+
return () => { layer.release(); trap.release(); window.removeEventListener('scroll', onScroll, true) }
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
function clearWell(well: Well) {
|
|
701
|
+
if (well === 'rows') emit({ ...layout, rows: [] })
|
|
702
|
+
else if (well === 'cols') emit({ ...layout, cols: [] })
|
|
703
|
+
else if (well === 'values') emit({ ...layout, values: [] })
|
|
704
|
+
else emit({ ...layout, filters: [] })
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Menu for a field-list (rail) row. */
|
|
708
|
+
function fieldMenu(field: string): MenuItem[] {
|
|
709
|
+
const f = fieldsByName.get(field)
|
|
710
|
+
if (!f) return []
|
|
711
|
+
const items: MenuItem[] = KIND_WELLS[f.kind].map((w) => ({
|
|
712
|
+
label: `Add to ${WELL_LABEL[w]}`,
|
|
713
|
+
icon: icPlus,
|
|
714
|
+
onSelect: () => addToWell(field, w),
|
|
715
|
+
}))
|
|
716
|
+
if (isFieldInLayout(field)) {
|
|
717
|
+
items.push({ separator: true }, { label: 'Remove from pivot', icon: icTrash, onSelect: () => toggleFieldDefault(field) })
|
|
718
|
+
}
|
|
719
|
+
return items
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Menu for a chip already sitting in a well. */
|
|
723
|
+
function chipMenu(well: Well, field: string, valueIndex?: number): MenuItem[] {
|
|
724
|
+
const f = fieldsByName.get(field)
|
|
725
|
+
const items: MenuItem[] = []
|
|
726
|
+
if (well === 'values' && valueIndex !== undefined) {
|
|
727
|
+
const current = layout.values[valueIndex]?.agg
|
|
728
|
+
items.push({
|
|
729
|
+
label: 'Aggregate',
|
|
730
|
+
icon: icSigma,
|
|
731
|
+
children: aggregators.map((agg) => ({
|
|
732
|
+
label: AGG_LABEL[agg],
|
|
733
|
+
shortcut: agg === current ? '✓' : undefined,
|
|
734
|
+
onSelect: () => setAggregatorAt(valueIndex, agg),
|
|
735
|
+
})),
|
|
736
|
+
})
|
|
737
|
+
}
|
|
738
|
+
if (f) {
|
|
739
|
+
const targets = KIND_WELLS[f.kind].filter((w) => w !== well)
|
|
740
|
+
if (targets.length) {
|
|
741
|
+
items.push({
|
|
742
|
+
label: 'Move to',
|
|
743
|
+
icon: icMove,
|
|
744
|
+
children: targets.map((w) => ({
|
|
745
|
+
label: WELL_LABEL[w],
|
|
746
|
+
onSelect: () => addToWell(field, w, Infinity, well === 'values' ? valueIndex : undefined),
|
|
747
|
+
})),
|
|
748
|
+
})
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (well === 'filters') items.push({ label: 'Edit filter…', icon: icFilter, onSelect: () => toggleFilterMenu(field) })
|
|
752
|
+
items.push(
|
|
753
|
+
{ separator: true },
|
|
754
|
+
{ label: 'Remove', icon: icTrash, onSelect: () => removeFromWell(field, well, valueIndex) },
|
|
755
|
+
{ label: `Clear ${WELL_LABEL[well]}`, icon: icClear, onSelect: () => clearWell(well) },
|
|
756
|
+
)
|
|
757
|
+
return items
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Menu for an empty area of a well. */
|
|
761
|
+
function wellMenu(well: Well): MenuItem[] {
|
|
762
|
+
const count = well === 'values' ? layout.values.length : layout[well].length
|
|
763
|
+
return [{ label: `Clear ${WELL_LABEL[well]}`, icon: icClear, disabled: count === 0, onSelect: () => clearWell(well) }]
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Menu for the pivot grid area (expand / collapse when expandable). */
|
|
767
|
+
function gridMenu(): MenuItem[] {
|
|
768
|
+
const items: MenuItem[] = []
|
|
769
|
+
if (expandable && pivot) {
|
|
770
|
+
items.push(
|
|
771
|
+
{ label: 'Expand all', icon: icExpand, onSelect: expandAll },
|
|
772
|
+
{ label: 'Collapse all', icon: icCollapse, onSelect: collapseAll },
|
|
773
|
+
)
|
|
774
|
+
}
|
|
775
|
+
if (onExport && pivot) {
|
|
776
|
+
if (items.length) items.push({ separator: true })
|
|
777
|
+
items.push({ label: 'Export…', icon: icExport, onSelect: () => onExport!(layout, pivot!.rows) })
|
|
778
|
+
}
|
|
779
|
+
return items
|
|
780
|
+
}
|
|
781
|
+
</script>
|
|
782
|
+
|
|
783
|
+
<section class="pvd" data-uid={uid}>
|
|
784
|
+
{#if showToolbar}
|
|
785
|
+
<header class="pvd-toolbar">
|
|
786
|
+
<strong class="pvd-title">Pivot designer</strong>
|
|
787
|
+
<button type="button" class="pvd-btn" onclick={reset} title="Restore the default layout">{@render ic('reset')} Reset</button>
|
|
788
|
+
{#if presets?.length}
|
|
789
|
+
<div class="pvd-presets" data-pvd-presets={uid}>
|
|
790
|
+
<button type="button" class="pvd-btn" onclick={togglePresets}>{@render ic('presets')} Presets {@render ic('chevron-down')}</button>
|
|
791
|
+
{#if presetsOpen}
|
|
792
|
+
<div class="pvd-popover">
|
|
793
|
+
{#each presets as p (p.name)}
|
|
794
|
+
<button type="button" class="pvd-popover-item" onclick={() => { loadPreset(p); presetsOpen = false }}>{p.name}</button>
|
|
795
|
+
{/each}
|
|
796
|
+
</div>
|
|
797
|
+
{/if}
|
|
798
|
+
</div>
|
|
799
|
+
{/if}
|
|
800
|
+
<label class="pvd-toggle">
|
|
801
|
+
<input type="checkbox" checked={!layout.hideSubtotals} onchange={toggleHideSubtotals} /> Subtotals
|
|
802
|
+
</label>
|
|
803
|
+
<label class="pvd-toggle">
|
|
804
|
+
<input type="checkbox" checked={!layout.hideGrandTotals} onchange={toggleHideGrandTotals} /> Grand totals
|
|
805
|
+
</label>
|
|
806
|
+
{#if chartable}
|
|
807
|
+
<div class="pvd-viewswitch" role="group" aria-label="View">
|
|
808
|
+
<button type="button" class="pvd-view-btn" class:is-active={view === 'table'} onclick={() => (view = 'table')}>{@render ic('table')} Table</button>
|
|
809
|
+
<button type="button" class="pvd-view-btn" class:is-active={view === 'chart'} onclick={() => (view = 'chart')}>{@render ic('chart')} Chart</button>
|
|
810
|
+
</div>
|
|
811
|
+
{#if view === 'chart'}
|
|
812
|
+
<label class="pvd-toggle">
|
|
813
|
+
<select class="pvd-select" value={chartType} onchange={(e) => (chartType = e.currentTarget.value as ChartType)}>
|
|
814
|
+
{#each CHART_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}
|
|
815
|
+
</select>
|
|
816
|
+
</label>
|
|
817
|
+
{#if chartType !== 'pie'}
|
|
818
|
+
<label class="pvd-toggle">
|
|
819
|
+
<input type="checkbox" checked={chartStacked} onchange={(e) => (chartStacked = e.currentTarget.checked)} /> Stacked
|
|
820
|
+
</label>
|
|
821
|
+
{/if}
|
|
822
|
+
{/if}
|
|
823
|
+
{/if}
|
|
824
|
+
<div class="pvd-spacer"></div>
|
|
825
|
+
{#if onExport && pivot}
|
|
826
|
+
<button type="button" class="pvd-btn pvd-btn-primary" onclick={() => onExport!(layout, pivot!.rows)}>{@render ic('export')} Export…</button>
|
|
827
|
+
{/if}
|
|
828
|
+
</header>
|
|
829
|
+
{/if}
|
|
830
|
+
|
|
831
|
+
<div class="pvd-body" data-panel={panelPosition} class:no-rail={!showFieldList && panelPosition === 'top'}>
|
|
832
|
+
{#if panelPosition === 'right'}
|
|
833
|
+
<div class="pvd-gridwrap">{@render gridBlock()}</div>
|
|
834
|
+
<aside class="pvd-panel" class:pvd-panel-tabbed={toolTabs} style={`width:${typeof panelWidth === 'number' ? panelWidth + 'px' : panelWidth}`} aria-label="Pivot panel">
|
|
835
|
+
{#if toolTabs}
|
|
836
|
+
<div class="pvd-tabbody">
|
|
837
|
+
{#if activeTab === 'columns'}{@render columnsPanel()}{:else}{@render filtersPanel()}{/if}
|
|
838
|
+
</div>
|
|
839
|
+
<div class="pvd-tabrail" role="tablist" aria-label="Tool panel tabs">
|
|
840
|
+
<button type="button" class="pvd-tabbtn" role="tab" aria-selected={activeTab === 'columns'} class:is-active={activeTab === 'columns'} onclick={() => (activeTab = 'columns')}>
|
|
841
|
+
{@render ic('columns')}<span>Columns</span>
|
|
842
|
+
</button>
|
|
843
|
+
<button type="button" class="pvd-tabbtn" role="tab" aria-selected={activeTab === 'filters'} class:is-active={activeTab === 'filters'} onclick={() => (activeTab = 'filters')}>
|
|
844
|
+
{@render ic('filter')}<span>Filters</span>
|
|
845
|
+
</button>
|
|
846
|
+
</div>
|
|
847
|
+
{:else}
|
|
848
|
+
{@render columnsPanel()}
|
|
849
|
+
{/if}
|
|
850
|
+
</aside>
|
|
851
|
+
{:else}
|
|
852
|
+
{#if showFieldList}
|
|
853
|
+
<aside class="pvd-rail" aria-label="Available fields">{@render railBlock()}</aside>
|
|
854
|
+
{/if}
|
|
855
|
+
<div class="pvd-main">
|
|
856
|
+
{#if showPivotToggle}<div class="pvd-topbar">{@render pivotToggle()}</div>{/if}
|
|
857
|
+
<div class="pvd-wells" class:two={!showFiltersWell}>{@render wellsBlock()}</div>
|
|
858
|
+
{@render gridBlock()}
|
|
859
|
+
</div>
|
|
860
|
+
{/if}
|
|
861
|
+
</div>
|
|
862
|
+
</section>
|
|
863
|
+
|
|
864
|
+
{#if ctx}
|
|
865
|
+
<div
|
|
866
|
+
bind:this={ctxPanelEl}
|
|
867
|
+
class="pvd-ctx"
|
|
868
|
+
use:portalToBody
|
|
869
|
+
use:popIn={{}}
|
|
870
|
+
style:position="fixed"
|
|
871
|
+
style:top={`${ctx.y}px`}
|
|
872
|
+
style:left={`${ctx.x}px`}
|
|
873
|
+
role="presentation"
|
|
874
|
+
>
|
|
875
|
+
<SvMenuList items={ctx.items} onclose={closeCtx} onselect={() => {}} />
|
|
876
|
+
</div>
|
|
877
|
+
{/if}
|
|
878
|
+
|
|
879
|
+
<!-- ============================ Icons ============================ -->
|
|
880
|
+
{#snippet ic(name: string)}
|
|
881
|
+
<svg class="pvd-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
882
|
+
{#if name === 'filter'}
|
|
883
|
+
<path d="M3 5h18l-7 8v6l-4 2v-8z" />
|
|
884
|
+
{:else if name === 'columns'}
|
|
885
|
+
<rect x="3" y="4" width="18" height="16" rx="1.5" />
|
|
886
|
+
<path d="M9 4v16" /><path d="M15 4v16" />
|
|
887
|
+
{:else if name === 'rows'}
|
|
888
|
+
<rect x="3" y="4" width="18" height="16" rx="1.5" />
|
|
889
|
+
<path d="M3 10h18" /><path d="M3 15h18" />
|
|
890
|
+
{:else if name === 'sigma'}
|
|
891
|
+
<path d="M17 5H7l6 7-6 7h10" />
|
|
892
|
+
{:else if name === 'dimension'}
|
|
893
|
+
<path d="M7 7h.01" />
|
|
894
|
+
<path d="M11 3h4.6a2 2 0 0 1 1.4.6l4 4a2 2 0 0 1 0 2.8l-6.6 6.6a2 2 0 0 1-2.8 0l-4-4a2 2 0 0 1-.6-1.4V6a3 3 0 0 1 3-3z" />
|
|
895
|
+
{:else if name === 'reset'}
|
|
896
|
+
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" /><path d="M3 3v5h5" />
|
|
897
|
+
{:else if name === 'presets'}
|
|
898
|
+
<path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5l8 4.5 8-4.5" />
|
|
899
|
+
{:else if name === 'export'}
|
|
900
|
+
<path d="M12 3v12" /><path d="M8 11l4 4 4-4" /><path d="M4 19h16" />
|
|
901
|
+
{:else if name === 'table'}
|
|
902
|
+
<rect x="3" y="4" width="18" height="16" rx="1.5" />
|
|
903
|
+
<path d="M3 9h18" /><path d="M9 9v11" />
|
|
904
|
+
{:else if name === 'chart'}
|
|
905
|
+
<path d="M4 20V4" /><path d="M4 20h16" />
|
|
906
|
+
<rect x="7" y="12" width="3" height="5" /><rect x="12" y="8" width="3" height="9" /><rect x="17" y="5" width="3" height="12" />
|
|
907
|
+
{:else if name === 'chevron-down'}
|
|
908
|
+
<path d="M6 9l6 6 6-6" />
|
|
909
|
+
{:else if name === 'grip'}
|
|
910
|
+
<circle cx="9" cy="6" r="1.3" fill="currentColor" stroke="none" /><circle cx="15" cy="6" r="1.3" fill="currentColor" stroke="none" />
|
|
911
|
+
<circle cx="9" cy="12" r="1.3" fill="currentColor" stroke="none" /><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none" />
|
|
912
|
+
<circle cx="9" cy="18" r="1.3" fill="currentColor" stroke="none" /><circle cx="15" cy="18" r="1.3" fill="currentColor" stroke="none" />
|
|
913
|
+
{:else if name === 'plus'}
|
|
914
|
+
<path d="M12 5v14" /><path d="M5 12h14" />
|
|
915
|
+
{:else if name === 'move'}
|
|
916
|
+
<path d="M5 9l-3 3 3 3" /><path d="M9 5l3-3 3 3" /><path d="M15 19l-3 3-3-3" /><path d="M19 9l3 3-3 3" /><path d="M2 12h20" /><path d="M12 2v20" />
|
|
917
|
+
{:else if name === 'trash'}
|
|
918
|
+
<path d="M4 7h16" /><path d="M9 7V5h6v2" /><path d="M6 7l1 13h10l1-13" />
|
|
919
|
+
{:else if name === 'clear'}
|
|
920
|
+
<path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6" /><path d="M14 11v6" />
|
|
921
|
+
{:else if name === 'expand'}
|
|
922
|
+
<path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M16 3h3a2 2 0 0 1 2 2v3" /><path d="M8 21H5a2 2 0 0 1-2-2v-3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
|
923
|
+
{:else if name === 'collapse'}
|
|
924
|
+
<path d="M3 8h3a2 2 0 0 0 2-2V3" /><path d="M21 8h-3a2 2 0 0 1-2-2V3" /><path d="M3 16h3a2 2 0 0 1 2 2v3" /><path d="M21 16h-3a2 2 0 0 0-2 2v3" />
|
|
925
|
+
{:else if name === 'pivot'}
|
|
926
|
+
<rect x="3" y="3" width="18" height="18" rx="1.5" /><path d="M3 9h18" /><path d="M9 9v12" />
|
|
927
|
+
{:else if name === 'check'}
|
|
928
|
+
<path d="M5 13l4 4 10-11" />
|
|
929
|
+
{:else if name === 'addfilter'}
|
|
930
|
+
<path d="M3 6h13" /><path d="M3 12h9" /><path d="M3 18h6" />
|
|
931
|
+
<path d="M17 13v8" /><path d="M13 17h8" />
|
|
932
|
+
{:else if name === 'search'}
|
|
933
|
+
<circle cx="11" cy="11" r="6" /><path d="M20 20l-4.5-4.5" />
|
|
934
|
+
{/if}
|
|
935
|
+
</svg>
|
|
936
|
+
{/snippet}
|
|
937
|
+
|
|
938
|
+
<!-- Zero-arg wrappers so icons can be attached to context-menu items. -->
|
|
939
|
+
{#snippet icPlus()}{@render ic('plus')}{/snippet}
|
|
940
|
+
{#snippet icTrash()}{@render ic('trash')}{/snippet}
|
|
941
|
+
{#snippet icClear()}{@render ic('clear')}{/snippet}
|
|
942
|
+
{#snippet icMove()}{@render ic('move')}{/snippet}
|
|
943
|
+
{#snippet icSigma()}{@render ic('sigma')}{/snippet}
|
|
944
|
+
{#snippet icFilter()}{@render ic('filter')}{/snippet}
|
|
945
|
+
{#snippet icExpand()}{@render ic('expand')}{/snippet}
|
|
946
|
+
{#snippet icCollapse()}{@render ic('collapse')}{/snippet}
|
|
947
|
+
{#snippet icExport()}{@render ic('export')}{/snippet}
|
|
948
|
+
|
|
949
|
+
<!-- ============================ Sub-blocks ============================ -->
|
|
950
|
+
{#snippet pivotToggle()}
|
|
951
|
+
<label class="pvd-pivot-toggle">
|
|
952
|
+
<input type="checkbox" checked={pivotMode} onchange={(e) => setPivotMode(e.currentTarget.checked)} />
|
|
953
|
+
<span class="pvd-switch" class:on={pivotMode}><span class="pvd-switch-knob"></span></span>
|
|
954
|
+
<span class="pvd-pivot-icon">{@render ic('pivot')}</span>
|
|
955
|
+
<span class="pvd-pivot-label">Pivot Mode</span>
|
|
956
|
+
</label>
|
|
957
|
+
{/snippet}
|
|
958
|
+
|
|
959
|
+
{#snippet railBlock()}
|
|
960
|
+
{#if columnTree}
|
|
961
|
+
<!-- AG-style Columns tool panel: select-all + search, then a collapsible
|
|
962
|
+
column-group TREE whose checkboxes toggle column visibility. Fields
|
|
963
|
+
still drag into the wells. -->
|
|
964
|
+
<div class="pvd-tree-top">
|
|
965
|
+
<input
|
|
966
|
+
type="checkbox"
|
|
967
|
+
class="pvd-tree-check"
|
|
968
|
+
checked={allVisibility === 'all'}
|
|
969
|
+
indeterminate={allVisibility === 'some'}
|
|
970
|
+
onchange={toggleAllVisible}
|
|
971
|
+
aria-label="Toggle all columns"
|
|
972
|
+
/>
|
|
973
|
+
<input type="search" class="pvd-search pvd-tree-search" placeholder="Search…" bind:value={search} />
|
|
974
|
+
</div>
|
|
975
|
+
<div class="pvd-fieldlist pvd-tree">
|
|
976
|
+
{#each groupedFields as [groupName, items] (groupName)}
|
|
977
|
+
{@const gv = groupVisibility(items)}
|
|
978
|
+
{@const open = !collapsedGroups.has(groupName)}
|
|
979
|
+
<div class="pvd-tree-group">
|
|
980
|
+
<button type="button" class="pvd-tree-twisty" class:is-open={open} onclick={() => toggleGroupCollapsed(groupName)} aria-label={open ? `Collapse ${groupName}` : `Expand ${groupName}`}>
|
|
981
|
+
{@render ic('chevron-down')}
|
|
982
|
+
</button>
|
|
983
|
+
<input type="checkbox" class="pvd-tree-check" checked={gv === 'all'} indeterminate={gv === 'some'} onchange={() => toggleGroupVisible(items)} aria-label={`Toggle ${groupName}`} />
|
|
984
|
+
<span class="pvd-grip" aria-hidden="true">{@render ic('grip')}</span>
|
|
985
|
+
<span class="pvd-tree-group-name">{groupName}</span>
|
|
986
|
+
</div>
|
|
987
|
+
{#if open}
|
|
988
|
+
{#each items as f (f.field)}
|
|
989
|
+
<div
|
|
990
|
+
class="pvd-tree-leaf"
|
|
991
|
+
draggable="true"
|
|
992
|
+
ondragstart={(e) => onDragStart(e, f.field, 'rail')}
|
|
993
|
+
oncontextmenu={(e) => openCtx(e, fieldMenu(f.field))}
|
|
994
|
+
role="button"
|
|
995
|
+
tabindex="0"
|
|
996
|
+
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setFieldVisible(f.field, !isFieldVisible(f.field)) } }}
|
|
997
|
+
>
|
|
998
|
+
<input type="checkbox" class="pvd-tree-check" checked={isFieldVisible(f.field)} onchange={(e) => setFieldVisible(f.field, e.currentTarget.checked)} aria-label={`Toggle ${f.label}`} />
|
|
999
|
+
<span class="pvd-grip" aria-hidden="true">{@render ic('grip')}</span>
|
|
1000
|
+
<span class="pvd-field-kind" class:is-measure={f.kind === 'measure'} title={f.kind === 'dimension' ? 'Dimension' : 'Measure'}>
|
|
1001
|
+
{@render ic(f.kind === 'dimension' ? 'dimension' : 'sigma')}
|
|
1002
|
+
</span>
|
|
1003
|
+
<span class="pvd-field-label">{f.label}</span>
|
|
1004
|
+
</div>
|
|
1005
|
+
{/each}
|
|
1006
|
+
{/if}
|
|
1007
|
+
{/each}
|
|
1008
|
+
{#if !filteredFields.length}
|
|
1009
|
+
<div class="pvd-empty">No columns match "{search}"</div>
|
|
1010
|
+
{/if}
|
|
1011
|
+
</div>
|
|
1012
|
+
{:else}
|
|
1013
|
+
<input
|
|
1014
|
+
type="search"
|
|
1015
|
+
class="pvd-search"
|
|
1016
|
+
placeholder="Search fields…"
|
|
1017
|
+
bind:value={search}
|
|
1018
|
+
/>
|
|
1019
|
+
<div class="pvd-fieldlist">
|
|
1020
|
+
{#each groupedFields as [groupName, items] (groupName)}
|
|
1021
|
+
<div class="pvd-group-head">{groupName}</div>
|
|
1022
|
+
{#each items as f (f.field)}
|
|
1023
|
+
{@const inUse = isFieldInLayout(f.field)}
|
|
1024
|
+
<div
|
|
1025
|
+
class="pvd-field"
|
|
1026
|
+
class:in-use={inUse}
|
|
1027
|
+
draggable="true"
|
|
1028
|
+
ondragstart={(e) => onDragStart(e, f.field, 'rail')}
|
|
1029
|
+
oncontextmenu={(e) => openCtx(e, fieldMenu(f.field))}
|
|
1030
|
+
role="button"
|
|
1031
|
+
tabindex="0"
|
|
1032
|
+
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleFieldDefault(f.field) } }}
|
|
1033
|
+
>
|
|
1034
|
+
<input type="checkbox" checked={inUse} onchange={() => toggleFieldDefault(f.field)} aria-label={`Toggle ${f.label}`} />
|
|
1035
|
+
<span class="pvd-field-kind" class:is-measure={f.kind === 'measure'} title={f.kind === 'dimension' ? 'Dimension' : 'Measure'}>
|
|
1036
|
+
{@render ic(f.kind === 'dimension' ? 'dimension' : 'sigma')}
|
|
1037
|
+
</span>
|
|
1038
|
+
<span class="pvd-field-label">{f.label}</span>
|
|
1039
|
+
</div>
|
|
1040
|
+
{/each}
|
|
1041
|
+
{/each}
|
|
1042
|
+
{#if !filteredFields.length}
|
|
1043
|
+
<div class="pvd-empty">No fields match "{search}"</div>
|
|
1044
|
+
{/if}
|
|
1045
|
+
</div>
|
|
1046
|
+
{/if}
|
|
1047
|
+
{/snippet}
|
|
1048
|
+
|
|
1049
|
+
{#snippet wellsBlock()}
|
|
1050
|
+
<!-- Filters -->
|
|
1051
|
+
{#if showFiltersWell}
|
|
1052
|
+
<div class="pvd-well"
|
|
1053
|
+
class:drag-over={dragOver === 'filters'}
|
|
1054
|
+
ondragover={(e) => onDragOver(e, 'filters')}
|
|
1055
|
+
ondragleave={onDragLeave}
|
|
1056
|
+
ondrop={(e) => onDrop(e, 'filters')}
|
|
1057
|
+
oncontextmenu={(e) => openCtx(e, wellMenu('filters'))}>
|
|
1058
|
+
<div class="pvd-well-head">{@render ic('filter')} Filters</div>
|
|
1059
|
+
<div class="pvd-well-body">
|
|
1060
|
+
{#each layout.filters as f (f.field)}
|
|
1061
|
+
{@const fd = fieldsByName.get(f.field)}
|
|
1062
|
+
<div class="pvd-chip" draggable="true" ondragstart={(e) => onDragStart(e, f.field, 'filters')} oncontextmenu={(e) => openCtx(e, chipMenu('filters', f.field))}>
|
|
1063
|
+
<span class="pvd-chip-grip" aria-hidden="true">{@render ic('grip')}</span>
|
|
1064
|
+
<button type="button" class="pvd-chip-label" title="Choose which values pass" onclick={() => toggleFilterMenu(f.field)}>
|
|
1065
|
+
{fd?.label ?? f.field}{f.allowed ? `: ${f.allowed.length}` : ''} <span class="pvd-chip-caret">▾</span>
|
|
1066
|
+
</button>
|
|
1067
|
+
<button type="button" class="pvd-chip-x" onclick={() => removeFromWell(f.field, 'filters')} aria-label="Remove">×</button>
|
|
1068
|
+
{#if openMenu?.kind === 'filter' && openMenu?.field === f.field}
|
|
1069
|
+
{@const all = distinctValuesFor(f.field)}
|
|
1070
|
+
<div class="pvd-popover pvd-popover-filter" data-pvd-menu={uid}>
|
|
1071
|
+
<div class="pvd-popover-head">
|
|
1072
|
+
<button type="button" class="pvd-popover-mini" onclick={() => setFilterAllowed(f.field, null)}>All</button>
|
|
1073
|
+
<button type="button" class="pvd-popover-mini" onclick={() => setFilterAllowed(f.field, [])}>None</button>
|
|
1074
|
+
</div>
|
|
1075
|
+
<div class="pvd-popover-list">
|
|
1076
|
+
{#each all as v (v)}
|
|
1077
|
+
{@const checked = f.allowed == null || f.allowed.includes(v)}
|
|
1078
|
+
<label class="pvd-popover-item pvd-popover-check">
|
|
1079
|
+
<input type="checkbox" checked={checked}
|
|
1080
|
+
onchange={() => {
|
|
1081
|
+
const cur = f.allowed ?? all.slice()
|
|
1082
|
+
const next = cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v]
|
|
1083
|
+
setFilterAllowed(f.field, next.length === all.length ? null : next)
|
|
1084
|
+
}} />
|
|
1085
|
+
<span>{v}</span>
|
|
1086
|
+
</label>
|
|
1087
|
+
{/each}
|
|
1088
|
+
</div>
|
|
1089
|
+
</div>
|
|
1090
|
+
{/if}
|
|
1091
|
+
</div>
|
|
1092
|
+
{/each}
|
|
1093
|
+
{#if !layout.filters.length}<span class="pvd-well-hint">drop fields here to filter the source rows</span>{/if}
|
|
1094
|
+
</div>
|
|
1095
|
+
</div>
|
|
1096
|
+
{/if}
|
|
1097
|
+
|
|
1098
|
+
<!-- Columns -->
|
|
1099
|
+
<div class="pvd-well"
|
|
1100
|
+
class:drag-over={dragOver === 'cols'}
|
|
1101
|
+
ondragover={(e) => onDragOver(e, 'cols')}
|
|
1102
|
+
ondragleave={onDragLeave}
|
|
1103
|
+
ondrop={(e) => onDrop(e, 'cols')}
|
|
1104
|
+
oncontextmenu={(e) => openCtx(e, wellMenu('cols'))}>
|
|
1105
|
+
<div class="pvd-well-head">{@render ic('columns')} Columns</div>
|
|
1106
|
+
<div class="pvd-well-body">
|
|
1107
|
+
{#each layout.cols as field (field)}
|
|
1108
|
+
{@const fd = fieldsByName.get(field)}
|
|
1109
|
+
<div class="pvd-chip" draggable="true" ondragstart={(e) => onDragStart(e, field, 'cols')} oncontextmenu={(e) => openCtx(e, chipMenu('cols', field))}>
|
|
1110
|
+
<span class="pvd-chip-grip" aria-hidden="true">{@render ic('grip')}</span>
|
|
1111
|
+
<span class="pvd-chip-label">{fd?.label ?? field}</span>
|
|
1112
|
+
<button type="button" class="pvd-chip-x" onclick={() => removeFromWell(field, 'cols')} aria-label="Remove">×</button>
|
|
1113
|
+
</div>
|
|
1114
|
+
{/each}
|
|
1115
|
+
{#if !layout.cols.length}<span class="pvd-well-hint">drop dimensions to pivot along the column axis</span>{/if}
|
|
1116
|
+
</div>
|
|
1117
|
+
</div>
|
|
1118
|
+
|
|
1119
|
+
<!-- Rows -->
|
|
1120
|
+
<div class="pvd-well"
|
|
1121
|
+
class:drag-over={dragOver === 'rows'}
|
|
1122
|
+
ondragover={(e) => onDragOver(e, 'rows')}
|
|
1123
|
+
ondragleave={onDragLeave}
|
|
1124
|
+
ondrop={(e) => onDrop(e, 'rows')}
|
|
1125
|
+
oncontextmenu={(e) => openCtx(e, wellMenu('rows'))}>
|
|
1126
|
+
<div class="pvd-well-head">{@render ic('rows')} Rows</div>
|
|
1127
|
+
<div class="pvd-well-body">
|
|
1128
|
+
{#each layout.rows as field (field)}
|
|
1129
|
+
{@const fd = fieldsByName.get(field)}
|
|
1130
|
+
<div class="pvd-chip" draggable="true" ondragstart={(e) => onDragStart(e, field, 'rows')} oncontextmenu={(e) => openCtx(e, chipMenu('rows', field))}>
|
|
1131
|
+
<span class="pvd-chip-grip" aria-hidden="true">{@render ic('grip')}</span>
|
|
1132
|
+
<span class="pvd-chip-label">{fd?.label ?? field}</span>
|
|
1133
|
+
<button type="button" class="pvd-chip-x" onclick={() => removeFromWell(field, 'rows')} aria-label="Remove">×</button>
|
|
1134
|
+
</div>
|
|
1135
|
+
{/each}
|
|
1136
|
+
{#if !layout.rows.length}<span class="pvd-well-hint">drop dimensions to group rows</span>{/if}
|
|
1137
|
+
</div>
|
|
1138
|
+
</div>
|
|
1139
|
+
|
|
1140
|
+
<!-- Values -->
|
|
1141
|
+
<div class="pvd-well"
|
|
1142
|
+
class:drag-over={dragOver === 'values'}
|
|
1143
|
+
ondragover={(e) => onDragOver(e, 'values')}
|
|
1144
|
+
ondragleave={onDragLeave}
|
|
1145
|
+
ondrop={(e) => onDrop(e, 'values')}
|
|
1146
|
+
oncontextmenu={(e) => openCtx(e, wellMenu('values'))}>
|
|
1147
|
+
<div class="pvd-well-head">{@render ic('sigma')} Values</div>
|
|
1148
|
+
<div class="pvd-well-body">
|
|
1149
|
+
{#each layout.values as v, vi (v.field + '|' + v.agg + '|' + vi)}
|
|
1150
|
+
{@const fd = fieldsByName.get(v.field)}
|
|
1151
|
+
<div class="pvd-chip pvd-chip-value" draggable="true" ondragstart={(e) => onDragStart(e, v.field, 'values', vi)} oncontextmenu={(e) => openCtx(e, chipMenu('values', v.field, vi))}>
|
|
1152
|
+
<span class="pvd-chip-grip" aria-hidden="true">{@render ic('grip')}</span>
|
|
1153
|
+
<button type="button" class="pvd-chip-label" onclick={() => toggleAggMenu(vi)}>
|
|
1154
|
+
<span class="pvd-chip-agg">{AGG_LABEL[v.agg]}</span>
|
|
1155
|
+
<span class="pvd-chip-sep">·</span>
|
|
1156
|
+
<span>{fd?.label ?? v.field}</span>
|
|
1157
|
+
</button>
|
|
1158
|
+
<button type="button" class="pvd-chip-x" onclick={() => removeFromWell(v.field, 'values', vi)} aria-label="Remove">×</button>
|
|
1159
|
+
{#if openMenu?.kind === 'agg' && openMenu.index === vi}
|
|
1160
|
+
<div class="pvd-popover" data-pvd-menu={uid}>
|
|
1161
|
+
{#each aggregators as agg (agg)}
|
|
1162
|
+
<button
|
|
1163
|
+
type="button"
|
|
1164
|
+
class="pvd-popover-item"
|
|
1165
|
+
class:is-active={v.agg === agg}
|
|
1166
|
+
onclick={() => { setAggregatorAt(vi, agg); closeMenu() }}
|
|
1167
|
+
>
|
|
1168
|
+
{AGG_LABEL[agg]}
|
|
1169
|
+
</button>
|
|
1170
|
+
{/each}
|
|
1171
|
+
</div>
|
|
1172
|
+
{/if}
|
|
1173
|
+
</div>
|
|
1174
|
+
{/each}
|
|
1175
|
+
{#if !layout.values.length}<span class="pvd-well-hint">drop measures to aggregate</span>{/if}
|
|
1176
|
+
</div>
|
|
1177
|
+
</div>
|
|
1178
|
+
{/snippet}
|
|
1179
|
+
|
|
1180
|
+
<!-- The Columns tab body: pivot toggle + field picker + the wells. -->
|
|
1181
|
+
{#snippet columnsPanel()}
|
|
1182
|
+
{#if showPivotToggle}{@render pivotToggle()}{/if}
|
|
1183
|
+
{#if showFieldList}<div class="pvd-panel-rail">{@render railBlock()}</div>{/if}
|
|
1184
|
+
<div class="pvd-wells pvd-wells--vertical" class:two={!showFiltersWell}>{@render wellsBlock()}</div>
|
|
1185
|
+
{/snippet}
|
|
1186
|
+
|
|
1187
|
+
<!-- The Filters tab body: AG-style set filters. Each active column filter is a
|
|
1188
|
+
collapsible card with (Select All) + a searchable value checklist; the
|
|
1189
|
+
"Add Filter" button opens a column picker to add another. Backed by the
|
|
1190
|
+
same `layout.filters` the pivot uses, so filters narrow the source rows. -->
|
|
1191
|
+
{#snippet filtersPanel()}
|
|
1192
|
+
<div class="pvd-filters-tab">
|
|
1193
|
+
{#each layout.filters as f (f.field)}
|
|
1194
|
+
{@const fd = fieldsByName.get(f.field)}
|
|
1195
|
+
{@const open = !filterCollapsed.has(f.field)}
|
|
1196
|
+
{@const all = distinctValuesFor(f.field)}
|
|
1197
|
+
{@const q = (filterSearch[f.field] ?? '').trim().toLowerCase()}
|
|
1198
|
+
{@const shown = q ? all.filter((v) => v.toLowerCase().includes(q)) : all}
|
|
1199
|
+
<div class="pvd-filt-card">
|
|
1200
|
+
<div class="pvd-filt-head">
|
|
1201
|
+
<button type="button" class="pvd-filt-title" onclick={() => toggleFilterCard(f.field)}>
|
|
1202
|
+
<span class="pvd-filt-twisty" class:is-open={open}>{@render ic('chevron-down')}</span>
|
|
1203
|
+
{fd?.label ?? f.field}{f.allowed ? ` (${f.allowed.length})` : ''}
|
|
1204
|
+
</button>
|
|
1205
|
+
<button type="button" class="pvd-filt-x" onclick={() => removeFromWell(f.field, 'filters')} aria-label={`Remove ${fd?.label ?? f.field} filter`}>×</button>
|
|
1206
|
+
</div>
|
|
1207
|
+
{#if open}
|
|
1208
|
+
<input class="pvd-filt-search" type="search" placeholder="Search…" value={filterSearch[f.field] ?? ''} oninput={(e) => (filterSearch = { ...filterSearch, [f.field]: e.currentTarget.value })} />
|
|
1209
|
+
<label class="pvd-filt-opt pvd-filt-all">
|
|
1210
|
+
<input type="checkbox" checked={f.allowed == null} indeterminate={!!f.allowed && f.allowed.length > 0} onchange={() => setFilterAllowed(f.field, f.allowed == null ? [] : null)} />
|
|
1211
|
+
<span>(Select All)</span>
|
|
1212
|
+
</label>
|
|
1213
|
+
<div class="pvd-filt-list">
|
|
1214
|
+
{#each shown as v (v)}
|
|
1215
|
+
<label class="pvd-filt-opt">
|
|
1216
|
+
<input type="checkbox" checked={f.allowed == null || f.allowed.includes(v)} onchange={() => toggleFilterValue(f, v, all)} />
|
|
1217
|
+
<span>{v}</span>
|
|
1218
|
+
</label>
|
|
1219
|
+
{/each}
|
|
1220
|
+
{#if !shown.length}<div class="pvd-empty">No values match "{filterSearch[f.field]}"</div>{/if}
|
|
1221
|
+
</div>
|
|
1222
|
+
{/if}
|
|
1223
|
+
</div>
|
|
1224
|
+
{/each}
|
|
1225
|
+
|
|
1226
|
+
<div class="pvd-addfilter" data-pvd-addfilter={uid}>
|
|
1227
|
+
<button type="button" class="pvd-addfilter-btn" onclick={() => (addFilterOpen = !addFilterOpen)}>
|
|
1228
|
+
{@render ic('addfilter')} Add Filter
|
|
1229
|
+
</button>
|
|
1230
|
+
{#if addFilterOpen}
|
|
1231
|
+
<div class="pvd-addfilter-menu">
|
|
1232
|
+
<div class="pvd-addfilter-searchwrap">
|
|
1233
|
+
<span class="pvd-addfilter-searchicon">{@render ic('search')}</span>
|
|
1234
|
+
<input class="pvd-addfilter-search" type="search" placeholder="Search columns…" bind:value={addFilterSearch} />
|
|
1235
|
+
</div>
|
|
1236
|
+
<div class="pvd-addfilter-list">
|
|
1237
|
+
{#each availableFilterFields as f (f.field)}
|
|
1238
|
+
<button type="button" class="pvd-addfilter-item" onclick={() => addFilterField(f.field)}>{f.label}</button>
|
|
1239
|
+
{/each}
|
|
1240
|
+
{#if !availableFilterFields.length}<div class="pvd-empty">No columns</div>{/if}
|
|
1241
|
+
</div>
|
|
1242
|
+
</div>
|
|
1243
|
+
{/if}
|
|
1244
|
+
</div>
|
|
1245
|
+
</div>
|
|
1246
|
+
{/snippet}
|
|
1247
|
+
|
|
1248
|
+
{#snippet gridBlock()}
|
|
1249
|
+
{#if embedGrid}
|
|
1250
|
+
<div class="pvd-grid" style={`height:${typeof gridHeight === 'number' ? gridHeight + 'px' : gridHeight}`} oncontextmenu={(e) => openCtx(e, gridMenu())}>
|
|
1251
|
+
{#if showPivotToggle && !pivotMode}
|
|
1252
|
+
<SvGrid
|
|
1253
|
+
data={filteredData}
|
|
1254
|
+
columns={visibleFlatColumns!}
|
|
1255
|
+
features={features}
|
|
1256
|
+
filterMode="row"
|
|
1257
|
+
sortable
|
|
1258
|
+
selectionMode="none"
|
|
1259
|
+
rowHeight={32}
|
|
1260
|
+
containerHeight="100%"
|
|
1261
|
+
fitColumns={gridFitColumns}
|
|
1262
|
+
columnVirtualization={columnVirtualization}
|
|
1263
|
+
enableRowSummaries={false}
|
|
1264
|
+
/>
|
|
1265
|
+
{:else if pivot && chartable && view === 'chart'}
|
|
1266
|
+
{#if chartSpec && chartSpec.series.length}
|
|
1267
|
+
<div class="pvd-chart"><SvGridChart spec={chartSpec} interactive legend /></div>
|
|
1268
|
+
{:else}
|
|
1269
|
+
<div class="pvd-empty pvd-grid-empty">Add a measure and a row / column field to chart the pivot.</div>
|
|
1270
|
+
{/if}
|
|
1271
|
+
{:else if pivot}
|
|
1272
|
+
<SvGrid
|
|
1273
|
+
data={visibleRows}
|
|
1274
|
+
columns={finalColumns}
|
|
1275
|
+
features={features}
|
|
1276
|
+
sortable
|
|
1277
|
+
filterable
|
|
1278
|
+
selectionMode="none"
|
|
1279
|
+
rowHeight={32}
|
|
1280
|
+
containerHeight="100%"
|
|
1281
|
+
fitColumns={gridFitColumns}
|
|
1282
|
+
columnVirtualization={columnVirtualization}
|
|
1283
|
+
enableRowSummaries={false}
|
|
1284
|
+
{onCellClick}
|
|
1285
|
+
/>
|
|
1286
|
+
{:else}
|
|
1287
|
+
<div class="pvd-empty pvd-grid-empty">
|
|
1288
|
+
{#if !layout.values.length}
|
|
1289
|
+
Drop at least one <strong>measure</strong> into Values to render the pivot.
|
|
1290
|
+
{:else}
|
|
1291
|
+
No data.
|
|
1292
|
+
{/if}
|
|
1293
|
+
</div>
|
|
1294
|
+
{/if}
|
|
1295
|
+
</div>
|
|
1296
|
+
{/if}
|
|
1297
|
+
{/snippet}
|
|
1298
|
+
|
|
1299
|
+
<!-- Label cell renderer used when `expandable` is on. Indents by
|
|
1300
|
+
pivot depth and shows a clickable chevron for expandable rows
|
|
1301
|
+
(group rows that have descendants). -->
|
|
1302
|
+
{#snippet ChevronLabelCell({ row, collapsed: isCollapsed, onToggle }: { row: PivotRow; collapsed: boolean; onToggle: () => void })}
|
|
1303
|
+
<span class="pvd-label-cell" style={`padding-left:${row.__pivotDepth * 14}px`}>
|
|
1304
|
+
{#if row.__pivotExpandable}
|
|
1305
|
+
<button
|
|
1306
|
+
type="button"
|
|
1307
|
+
class="pvd-chev"
|
|
1308
|
+
class:is-collapsed={isCollapsed}
|
|
1309
|
+
onclick={(e) => { e.stopPropagation(); onToggle() }}
|
|
1310
|
+
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
|
|
1311
|
+
>▾</button>
|
|
1312
|
+
{:else}
|
|
1313
|
+
<span class="pvd-chev pvd-chev-placeholder"></span>
|
|
1314
|
+
{/if}
|
|
1315
|
+
<span class="pvd-label-text" class:is-subtotal={row.__pivotKind === 'subtotal'} class:is-grand={row.__pivotKind === 'grandTotal'}>
|
|
1316
|
+
{row.__pivotLabel}
|
|
1317
|
+
</span>
|
|
1318
|
+
</span>
|
|
1319
|
+
{/snippet}
|
|
1320
|
+
|
|
1321
|
+
<style>
|
|
1322
|
+
/* SvPivotDesigner styles -------------------------------------------
|
|
1323
|
+
All variables use the SvGrid token system (--sg-*) with safe
|
|
1324
|
+
fallbacks so the component theme-matches whatever grid skin the
|
|
1325
|
+
host page is using. */
|
|
1326
|
+
.pvd {
|
|
1327
|
+
display: flex;
|
|
1328
|
+
flex-direction: column;
|
|
1329
|
+
width: 100%;
|
|
1330
|
+
height: 100%;
|
|
1331
|
+
min-height: 0;
|
|
1332
|
+
color: var(--sg-fg, #0f172a);
|
|
1333
|
+
background: var(--sg-bg, #ffffff);
|
|
1334
|
+
border: 1px solid var(--sg-border, #e2e8f0);
|
|
1335
|
+
border-radius: 8px;
|
|
1336
|
+
overflow: hidden;
|
|
1337
|
+
font-family: inherit;
|
|
1338
|
+
}
|
|
1339
|
+
.pvd-toolbar {
|
|
1340
|
+
display: flex;
|
|
1341
|
+
align-items: center;
|
|
1342
|
+
gap: 8px;
|
|
1343
|
+
padding: 8px 12px;
|
|
1344
|
+
background: var(--sg-header-bg, #f8fafc);
|
|
1345
|
+
border-bottom: 1px solid var(--sg-border, #e2e8f0);
|
|
1346
|
+
flex-shrink: 0;
|
|
1347
|
+
}
|
|
1348
|
+
.pvd-title {
|
|
1349
|
+
font-size: 13px;
|
|
1350
|
+
font-weight: 700;
|
|
1351
|
+
color: var(--sg-fg);
|
|
1352
|
+
margin-right: 8px;
|
|
1353
|
+
}
|
|
1354
|
+
.pvd-spacer { flex: 1; }
|
|
1355
|
+
.pvd-btn {
|
|
1356
|
+
display: inline-flex;
|
|
1357
|
+
align-items: center;
|
|
1358
|
+
gap: 5px;
|
|
1359
|
+
border: 1px solid var(--sg-border, #cbd5e1);
|
|
1360
|
+
background: var(--sg-bg, #ffffff);
|
|
1361
|
+
color: var(--sg-fg, #1e293b);
|
|
1362
|
+
padding: 4px 10px;
|
|
1363
|
+
border-radius: 5px;
|
|
1364
|
+
font-size: 12px;
|
|
1365
|
+
font-weight: 600;
|
|
1366
|
+
cursor: pointer;
|
|
1367
|
+
transition: background 100ms ease, border-color 100ms ease;
|
|
1368
|
+
}
|
|
1369
|
+
.pvd-btn .pvd-ic { color: var(--sg-muted, #64748b); }
|
|
1370
|
+
.pvd-btn-primary .pvd-ic { color: currentColor; }
|
|
1371
|
+
.pvd-btn:hover { background: var(--sg-row-hover-bg, #f1f5f9); border-color: var(--sg-accent, #2563eb); }
|
|
1372
|
+
.pvd-btn-primary {
|
|
1373
|
+
background: var(--sg-accent, #2563eb);
|
|
1374
|
+
color: #fff;
|
|
1375
|
+
border-color: var(--sg-accent, #2563eb);
|
|
1376
|
+
}
|
|
1377
|
+
.pvd-btn-primary:hover { opacity: 0.9; }
|
|
1378
|
+
.pvd-toggle {
|
|
1379
|
+
display: inline-flex;
|
|
1380
|
+
align-items: center;
|
|
1381
|
+
gap: 4px;
|
|
1382
|
+
font-size: 12px;
|
|
1383
|
+
color: var(--sg-fg);
|
|
1384
|
+
cursor: pointer;
|
|
1385
|
+
user-select: none;
|
|
1386
|
+
}
|
|
1387
|
+
.pvd-toggle input { accent-color: var(--sg-accent, #2563eb); }
|
|
1388
|
+
|
|
1389
|
+
.pvd-body {
|
|
1390
|
+
display: grid;
|
|
1391
|
+
grid-template-columns: 220px 1fr;
|
|
1392
|
+
gap: 0;
|
|
1393
|
+
flex: 1;
|
|
1394
|
+
min-height: 0;
|
|
1395
|
+
}
|
|
1396
|
+
.pvd-body.no-rail { grid-template-columns: 1fr; }
|
|
1397
|
+
|
|
1398
|
+
/* Left rail */
|
|
1399
|
+
.pvd-rail {
|
|
1400
|
+
display: flex;
|
|
1401
|
+
flex-direction: column;
|
|
1402
|
+
border-right: 1px solid var(--sg-border, #e2e8f0);
|
|
1403
|
+
background: var(--sg-bg, #ffffff);
|
|
1404
|
+
min-height: 0;
|
|
1405
|
+
}
|
|
1406
|
+
.pvd-search {
|
|
1407
|
+
border: 0;
|
|
1408
|
+
border-bottom: 1px solid var(--sg-border, #e2e8f0);
|
|
1409
|
+
padding: 8px 12px;
|
|
1410
|
+
font-size: 12px;
|
|
1411
|
+
background: transparent;
|
|
1412
|
+
color: var(--sg-fg);
|
|
1413
|
+
outline: none;
|
|
1414
|
+
}
|
|
1415
|
+
.pvd-search:focus { background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
1416
|
+
.pvd-fieldlist {
|
|
1417
|
+
flex: 1;
|
|
1418
|
+
min-height: 0;
|
|
1419
|
+
overflow: auto;
|
|
1420
|
+
padding: 4px 0;
|
|
1421
|
+
}
|
|
1422
|
+
.pvd-group-head {
|
|
1423
|
+
padding: 8px 12px 4px;
|
|
1424
|
+
font-size: 10.5px;
|
|
1425
|
+
font-weight: 700;
|
|
1426
|
+
text-transform: uppercase;
|
|
1427
|
+
letter-spacing: 0.06em;
|
|
1428
|
+
color: var(--sg-muted, #64748b);
|
|
1429
|
+
}
|
|
1430
|
+
.pvd-field {
|
|
1431
|
+
display: grid;
|
|
1432
|
+
grid-template-columns: 16px auto 1fr;
|
|
1433
|
+
align-items: center;
|
|
1434
|
+
gap: 8px;
|
|
1435
|
+
padding: 5px 12px;
|
|
1436
|
+
font-size: 12px;
|
|
1437
|
+
cursor: grab;
|
|
1438
|
+
transition: background 80ms ease;
|
|
1439
|
+
}
|
|
1440
|
+
.pvd-field:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
1441
|
+
.pvd-field:active { cursor: grabbing; }
|
|
1442
|
+
.pvd-field.in-use .pvd-field-label { font-weight: 600; color: var(--sg-accent, #2563eb); }
|
|
1443
|
+
.pvd-field-label {
|
|
1444
|
+
overflow: hidden;
|
|
1445
|
+
text-overflow: ellipsis;
|
|
1446
|
+
white-space: nowrap;
|
|
1447
|
+
}
|
|
1448
|
+
/* Dimension vs measure indicator - a tinted icon puck. */
|
|
1449
|
+
.pvd-field-kind {
|
|
1450
|
+
display: inline-flex;
|
|
1451
|
+
align-items: center;
|
|
1452
|
+
justify-content: center;
|
|
1453
|
+
width: 20px;
|
|
1454
|
+
height: 20px;
|
|
1455
|
+
border-radius: 4px;
|
|
1456
|
+
color: var(--sg-accent, #2563eb);
|
|
1457
|
+
background: color-mix(in srgb, var(--sg-accent, #2563eb) 12%, transparent);
|
|
1458
|
+
}
|
|
1459
|
+
.pvd-field-kind.is-measure {
|
|
1460
|
+
color: #0e9f6e;
|
|
1461
|
+
background: color-mix(in srgb, #0e9f6e 12%, transparent);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/* Base icon sizing (inline SVGs from the `ic` snippet). */
|
|
1465
|
+
:global(.pvd .pvd-ic),
|
|
1466
|
+
:global(.pvd-ctx .pvd-ic) {
|
|
1467
|
+
width: 14px;
|
|
1468
|
+
height: 14px;
|
|
1469
|
+
flex: none;
|
|
1470
|
+
display: block;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/* ---- Columns tool panel: visibility tree (columnTree) ------------ */
|
|
1474
|
+
.pvd-tree-top {
|
|
1475
|
+
display: flex;
|
|
1476
|
+
align-items: center;
|
|
1477
|
+
gap: 8px;
|
|
1478
|
+
padding: 10px 12px 6px;
|
|
1479
|
+
}
|
|
1480
|
+
.pvd-tree-search {
|
|
1481
|
+
flex: 1;
|
|
1482
|
+
border: 1px solid var(--sg-input-border, var(--sg-border, #e2e8f0));
|
|
1483
|
+
border-radius: var(--sg-radius, 6px);
|
|
1484
|
+
padding: 6px 9px;
|
|
1485
|
+
}
|
|
1486
|
+
.pvd-tree-check { accent-color: var(--sg-accent, #2563eb); width: 14px; height: 14px; flex: none; cursor: pointer; }
|
|
1487
|
+
.pvd-tree { padding: 2px 0 6px; }
|
|
1488
|
+
.pvd-tree-group {
|
|
1489
|
+
display: flex;
|
|
1490
|
+
align-items: center;
|
|
1491
|
+
gap: 6px;
|
|
1492
|
+
padding: 4px 12px 4px 6px;
|
|
1493
|
+
cursor: default;
|
|
1494
|
+
}
|
|
1495
|
+
.pvd-tree-group:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
1496
|
+
.pvd-tree-group-name {
|
|
1497
|
+
font-size: 12px;
|
|
1498
|
+
font-weight: 700;
|
|
1499
|
+
color: var(--sg-fg, #0f172a);
|
|
1500
|
+
overflow: hidden;
|
|
1501
|
+
text-overflow: ellipsis;
|
|
1502
|
+
white-space: nowrap;
|
|
1503
|
+
}
|
|
1504
|
+
.pvd-tree-twisty {
|
|
1505
|
+
display: inline-flex;
|
|
1506
|
+
align-items: center;
|
|
1507
|
+
justify-content: center;
|
|
1508
|
+
width: 16px;
|
|
1509
|
+
height: 16px;
|
|
1510
|
+
padding: 0;
|
|
1511
|
+
border: 0;
|
|
1512
|
+
background: transparent;
|
|
1513
|
+
color: var(--sg-muted, #64748b);
|
|
1514
|
+
cursor: pointer;
|
|
1515
|
+
transition: transform 100ms ease;
|
|
1516
|
+
}
|
|
1517
|
+
.pvd-tree-twisty:not(.is-open) { transform: rotate(-90deg); }
|
|
1518
|
+
.pvd-tree-leaf {
|
|
1519
|
+
display: flex;
|
|
1520
|
+
align-items: center;
|
|
1521
|
+
gap: 8px;
|
|
1522
|
+
/* Indent past the twisty so leaves nest under their group. */
|
|
1523
|
+
padding: 4px 12px 4px 30px;
|
|
1524
|
+
font-size: 12px;
|
|
1525
|
+
cursor: grab;
|
|
1526
|
+
transition: background 80ms ease;
|
|
1527
|
+
}
|
|
1528
|
+
.pvd-tree-leaf:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
1529
|
+
.pvd-tree-leaf:active { cursor: grabbing; }
|
|
1530
|
+
.pvd-tree-leaf .pvd-field-label { flex: 1; }
|
|
1531
|
+
.pvd-tree .pvd-grip { display: inline-flex; align-items: center; color: var(--sg-muted, #cbd5e1); }
|
|
1532
|
+
.pvd-tree .pvd-grip .pvd-ic { width: 12px; height: 12px; }
|
|
1533
|
+
|
|
1534
|
+
/* Main area */
|
|
1535
|
+
.pvd-main {
|
|
1536
|
+
display: flex;
|
|
1537
|
+
flex-direction: column;
|
|
1538
|
+
min-height: 0;
|
|
1539
|
+
min-width: 0;
|
|
1540
|
+
}
|
|
1541
|
+
.pvd-wells {
|
|
1542
|
+
display: grid;
|
|
1543
|
+
grid-template-columns: repeat(4, 1fr);
|
|
1544
|
+
gap: 8px;
|
|
1545
|
+
padding: 8px;
|
|
1546
|
+
background: var(--sg-bg, #ffffff);
|
|
1547
|
+
border-bottom: 1px solid var(--sg-border, #e2e8f0);
|
|
1548
|
+
}
|
|
1549
|
+
.pvd-wells.two { grid-template-columns: repeat(3, 1fr); }
|
|
1550
|
+
|
|
1551
|
+
.pvd-well {
|
|
1552
|
+
display: flex;
|
|
1553
|
+
flex-direction: column;
|
|
1554
|
+
border: 1px dashed var(--sg-border, #cbd5e1);
|
|
1555
|
+
border-radius: 6px;
|
|
1556
|
+
background: var(--sg-bg, #ffffff);
|
|
1557
|
+
min-height: 64px;
|
|
1558
|
+
transition: border-color 100ms ease, background 100ms ease;
|
|
1559
|
+
}
|
|
1560
|
+
.pvd-well.drag-over {
|
|
1561
|
+
border-color: var(--sg-accent, #2563eb);
|
|
1562
|
+
border-style: solid;
|
|
1563
|
+
background: color-mix(in srgb, var(--sg-accent, #2563eb) 8%, var(--sg-bg, #ffffff));
|
|
1564
|
+
}
|
|
1565
|
+
.pvd-well-head {
|
|
1566
|
+
display: flex;
|
|
1567
|
+
align-items: center;
|
|
1568
|
+
gap: 5px;
|
|
1569
|
+
padding: 4px 8px 2px;
|
|
1570
|
+
font-size: 10.5px;
|
|
1571
|
+
font-weight: 700;
|
|
1572
|
+
text-transform: uppercase;
|
|
1573
|
+
letter-spacing: 0.06em;
|
|
1574
|
+
color: var(--sg-muted, #64748b);
|
|
1575
|
+
}
|
|
1576
|
+
.pvd-well-head .pvd-ic { color: var(--sg-accent, #2563eb); }
|
|
1577
|
+
.pvd-well-body {
|
|
1578
|
+
display: flex;
|
|
1579
|
+
flex-wrap: wrap;
|
|
1580
|
+
gap: 4px;
|
|
1581
|
+
padding: 4px 8px 8px;
|
|
1582
|
+
min-height: 36px;
|
|
1583
|
+
align-content: flex-start;
|
|
1584
|
+
}
|
|
1585
|
+
.pvd-well-hint {
|
|
1586
|
+
color: var(--sg-muted, #94a3b8);
|
|
1587
|
+
font-size: 11px;
|
|
1588
|
+
font-style: italic;
|
|
1589
|
+
padding: 4px 0;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
.pvd-chip {
|
|
1593
|
+
position: relative;
|
|
1594
|
+
display: inline-flex;
|
|
1595
|
+
align-items: center;
|
|
1596
|
+
gap: 2px;
|
|
1597
|
+
background: var(--sg-header-bg, #f1f5f9);
|
|
1598
|
+
border: 1px solid var(--sg-border, #cbd5e1);
|
|
1599
|
+
border-radius: 5px;
|
|
1600
|
+
font-size: 12px;
|
|
1601
|
+
color: var(--sg-fg, #0f172a);
|
|
1602
|
+
cursor: grab;
|
|
1603
|
+
user-select: none;
|
|
1604
|
+
}
|
|
1605
|
+
.pvd-chip:active { cursor: grabbing; }
|
|
1606
|
+
.pvd-chip-value {
|
|
1607
|
+
background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, var(--sg-bg, #ffffff));
|
|
1608
|
+
border-color: var(--sg-accent, #2563eb);
|
|
1609
|
+
}
|
|
1610
|
+
.pvd-chip-grip {
|
|
1611
|
+
display: inline-flex;
|
|
1612
|
+
align-items: center;
|
|
1613
|
+
padding-left: 4px;
|
|
1614
|
+
color: var(--sg-muted, #94a3b8);
|
|
1615
|
+
cursor: grab;
|
|
1616
|
+
}
|
|
1617
|
+
.pvd-chip-grip .pvd-ic { width: 12px; height: 12px; }
|
|
1618
|
+
.pvd-chip-label {
|
|
1619
|
+
border: 0;
|
|
1620
|
+
background: transparent;
|
|
1621
|
+
padding: 4px 6px 4px 4px;
|
|
1622
|
+
font: inherit;
|
|
1623
|
+
color: inherit;
|
|
1624
|
+
cursor: pointer;
|
|
1625
|
+
}
|
|
1626
|
+
.pvd-chip-agg {
|
|
1627
|
+
font-size: 10.5px;
|
|
1628
|
+
font-weight: 700;
|
|
1629
|
+
color: var(--sg-accent, #2563eb);
|
|
1630
|
+
text-transform: uppercase;
|
|
1631
|
+
letter-spacing: 0.04em;
|
|
1632
|
+
margin-right: 4px;
|
|
1633
|
+
}
|
|
1634
|
+
.pvd-chip-sep { color: var(--sg-muted, #94a3b8); margin-right: 4px; }
|
|
1635
|
+
.pvd-chip-x {
|
|
1636
|
+
border: 0;
|
|
1637
|
+
background: transparent;
|
|
1638
|
+
color: var(--sg-muted, #64748b);
|
|
1639
|
+
cursor: pointer;
|
|
1640
|
+
padding: 2px 6px 3px;
|
|
1641
|
+
font-size: 14px;
|
|
1642
|
+
line-height: 1;
|
|
1643
|
+
border-radius: 3px;
|
|
1644
|
+
}
|
|
1645
|
+
.pvd-chip-x:hover { color: #ef4444; background: rgba(239, 68, 68, 0.08); }
|
|
1646
|
+
|
|
1647
|
+
/* Popovers (agg menu, filter menu, presets) */
|
|
1648
|
+
.pvd-popover, .pvd-presets {
|
|
1649
|
+
position: relative;
|
|
1650
|
+
}
|
|
1651
|
+
.pvd-popover {
|
|
1652
|
+
position: absolute;
|
|
1653
|
+
top: calc(100% + 4px);
|
|
1654
|
+
left: 0;
|
|
1655
|
+
z-index: 50;
|
|
1656
|
+
min-width: 160px;
|
|
1657
|
+
background: var(--sg-bg, #ffffff);
|
|
1658
|
+
border: 1px solid var(--sg-border, #cbd5e1);
|
|
1659
|
+
border-radius: 6px;
|
|
1660
|
+
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
|
|
1661
|
+
padding: 4px;
|
|
1662
|
+
}
|
|
1663
|
+
.pvd-popover-filter { min-width: 200px; max-height: 280px; display: flex; flex-direction: column; }
|
|
1664
|
+
.pvd-popover-head {
|
|
1665
|
+
display: flex; gap: 4px;
|
|
1666
|
+
padding: 4px;
|
|
1667
|
+
border-bottom: 1px solid var(--sg-border, #e2e8f0);
|
|
1668
|
+
}
|
|
1669
|
+
.pvd-popover-mini {
|
|
1670
|
+
flex: 1;
|
|
1671
|
+
border: 1px solid var(--sg-border, #cbd5e1);
|
|
1672
|
+
background: var(--sg-bg, #ffffff);
|
|
1673
|
+
color: var(--sg-fg);
|
|
1674
|
+
padding: 2px 6px;
|
|
1675
|
+
border-radius: 4px;
|
|
1676
|
+
font-size: 11px;
|
|
1677
|
+
cursor: pointer;
|
|
1678
|
+
}
|
|
1679
|
+
.pvd-popover-mini:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
1680
|
+
.pvd-popover-list { flex: 1; min-height: 0; overflow: auto; padding: 4px 0; }
|
|
1681
|
+
.pvd-popover-item {
|
|
1682
|
+
display: block;
|
|
1683
|
+
width: 100%;
|
|
1684
|
+
text-align: left;
|
|
1685
|
+
border: 0;
|
|
1686
|
+
background: transparent;
|
|
1687
|
+
padding: 5px 10px;
|
|
1688
|
+
font-size: 12px;
|
|
1689
|
+
color: var(--sg-fg);
|
|
1690
|
+
cursor: pointer;
|
|
1691
|
+
border-radius: 4px;
|
|
1692
|
+
}
|
|
1693
|
+
.pvd-popover-item:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
1694
|
+
.pvd-popover-item.is-active { background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, transparent); color: var(--sg-accent, #2563eb); font-weight: 600; }
|
|
1695
|
+
.pvd-popover-check {
|
|
1696
|
+
display: flex; align-items: center; gap: 8px;
|
|
1697
|
+
cursor: pointer;
|
|
1698
|
+
}
|
|
1699
|
+
.pvd-popover-check input { accent-color: var(--sg-accent, #2563eb); }
|
|
1700
|
+
|
|
1701
|
+
/* Embedded grid */
|
|
1702
|
+
.pvd-grid {
|
|
1703
|
+
flex: 1;
|
|
1704
|
+
min-height: 0;
|
|
1705
|
+
padding: 0;
|
|
1706
|
+
}
|
|
1707
|
+
.pvd-grid-empty {
|
|
1708
|
+
display: flex;
|
|
1709
|
+
align-items: center;
|
|
1710
|
+
justify-content: center;
|
|
1711
|
+
height: 100%;
|
|
1712
|
+
color: var(--sg-muted, #94a3b8);
|
|
1713
|
+
font-size: 13px;
|
|
1714
|
+
padding: 24px;
|
|
1715
|
+
text-align: center;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
.pvd-empty {
|
|
1719
|
+
padding: 20px;
|
|
1720
|
+
color: var(--sg-muted, #94a3b8);
|
|
1721
|
+
font-size: 12px;
|
|
1722
|
+
text-align: center;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/* Built-in label cell renderer (when expandable is on) */
|
|
1726
|
+
:global(.pvd-label-cell) {
|
|
1727
|
+
display: inline-flex;
|
|
1728
|
+
align-items: center;
|
|
1729
|
+
gap: 4px;
|
|
1730
|
+
line-height: 1;
|
|
1731
|
+
}
|
|
1732
|
+
:global(.pvd-chev) {
|
|
1733
|
+
width: 14px;
|
|
1734
|
+
height: 14px;
|
|
1735
|
+
border: 0;
|
|
1736
|
+
background: transparent;
|
|
1737
|
+
color: var(--sg-muted, #64748b);
|
|
1738
|
+
font-size: 10px;
|
|
1739
|
+
line-height: 14px;
|
|
1740
|
+
cursor: pointer;
|
|
1741
|
+
border-radius: 3px;
|
|
1742
|
+
padding: 0;
|
|
1743
|
+
transition: transform 100ms ease, background 100ms ease;
|
|
1744
|
+
}
|
|
1745
|
+
:global(.pvd-chev:hover) { background: var(--sg-row-hover-bg, #f1f5f9); color: var(--sg-fg, #0f172a); }
|
|
1746
|
+
:global(.pvd-chev.is-collapsed) { transform: rotate(-90deg); }
|
|
1747
|
+
:global(.pvd-chev-placeholder) { cursor: default; visibility: hidden; }
|
|
1748
|
+
:global(.pvd-label-text.is-subtotal) { font-weight: 700; }
|
|
1749
|
+
:global(.pvd-label-text.is-grand) { font-weight: 800; color: var(--sg-accent, #2563eb); }
|
|
1750
|
+
|
|
1751
|
+
/* Mobile: stack rail above main, wells in two columns. */
|
|
1752
|
+
@media (max-width: 900px) {
|
|
1753
|
+
.pvd-body { grid-template-columns: 1fr; }
|
|
1754
|
+
.pvd-rail {
|
|
1755
|
+
border-right: 0;
|
|
1756
|
+
border-bottom: 1px solid var(--sg-border, #e2e8f0);
|
|
1757
|
+
max-height: 200px;
|
|
1758
|
+
}
|
|
1759
|
+
.pvd-wells, .pvd-wells.two { grid-template-columns: repeat(2, 1fr); }
|
|
1760
|
+
}
|
|
1761
|
+
.pvd-chip-caret { font-size: 9px; color: var(--sg-muted, #94a3b8); }
|
|
1762
|
+
.pvd-select {
|
|
1763
|
+
font: inherit; font-size: 12px; color: var(--sg-fg);
|
|
1764
|
+
background: var(--sg-input-bg, var(--sg-bg, #fff));
|
|
1765
|
+
border: 1px solid var(--sg-input-border, var(--sg-border, #e2e8f0));
|
|
1766
|
+
border-radius: var(--sg-radius, 6px); padding: 3px 6px;
|
|
1767
|
+
}
|
|
1768
|
+
.pvd-viewswitch { display: inline-flex; border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 6px); overflow: hidden; }
|
|
1769
|
+
.pvd-view-btn { display: inline-flex; align-items: center; gap: 5px; font: inherit; font-size: 12px; padding: 3px 12px; border: 0; background: var(--sg-bg, #fff); color: var(--sg-muted, #64748b); cursor: pointer; }
|
|
1770
|
+
.pvd-view-btn.is-active { background: var(--sg-accent, #2563eb); color: #fff; }
|
|
1771
|
+
.pvd-chart { width: 100%; height: 100%; padding: 8px 10px; box-sizing: border-box; overflow: hidden; display: flex; }
|
|
1772
|
+
.pvd-chart > :global(.sv-grid-chart) { width: 100%; }
|
|
1773
|
+
|
|
1774
|
+
/* ---- Right-docked tool panel (panelPosition="right") -------------- */
|
|
1775
|
+
.pvd-body[data-panel='right'] {
|
|
1776
|
+
display: flex;
|
|
1777
|
+
flex-direction: row;
|
|
1778
|
+
}
|
|
1779
|
+
.pvd-gridwrap {
|
|
1780
|
+
flex: 1;
|
|
1781
|
+
min-width: 0;
|
|
1782
|
+
min-height: 0;
|
|
1783
|
+
display: flex;
|
|
1784
|
+
flex-direction: column;
|
|
1785
|
+
}
|
|
1786
|
+
.pvd-gridwrap .pvd-grid { flex: 1; min-height: 0; }
|
|
1787
|
+
.pvd-panel {
|
|
1788
|
+
flex-shrink: 0;
|
|
1789
|
+
display: flex;
|
|
1790
|
+
flex-direction: column;
|
|
1791
|
+
min-height: 0;
|
|
1792
|
+
border-left: 1px solid var(--sg-border, #e2e8f0);
|
|
1793
|
+
background: var(--sg-header-bg, #f8fafc);
|
|
1794
|
+
overflow-y: auto;
|
|
1795
|
+
}
|
|
1796
|
+
/* Tabbed panel: content column + a vertical Columns/Filters rail on the right. */
|
|
1797
|
+
.pvd-panel-tabbed {
|
|
1798
|
+
flex-direction: row;
|
|
1799
|
+
overflow: hidden;
|
|
1800
|
+
}
|
|
1801
|
+
.pvd-tabbody {
|
|
1802
|
+
flex: 1;
|
|
1803
|
+
min-width: 0;
|
|
1804
|
+
min-height: 0;
|
|
1805
|
+
display: flex;
|
|
1806
|
+
flex-direction: column;
|
|
1807
|
+
overflow-y: auto;
|
|
1808
|
+
}
|
|
1809
|
+
.pvd-tabrail {
|
|
1810
|
+
flex: none;
|
|
1811
|
+
display: flex;
|
|
1812
|
+
flex-direction: column;
|
|
1813
|
+
border-left: 1px solid var(--sg-border, #e2e8f0);
|
|
1814
|
+
background: var(--sg-bg, #fff);
|
|
1815
|
+
}
|
|
1816
|
+
.pvd-tabbtn {
|
|
1817
|
+
display: flex;
|
|
1818
|
+
flex-direction: column;
|
|
1819
|
+
align-items: center;
|
|
1820
|
+
gap: 6px;
|
|
1821
|
+
padding: 12px 7px;
|
|
1822
|
+
border: 0;
|
|
1823
|
+
border-left: 2px solid transparent;
|
|
1824
|
+
background: transparent;
|
|
1825
|
+
color: var(--sg-muted, #64748b);
|
|
1826
|
+
cursor: pointer;
|
|
1827
|
+
font-size: 11.5px;
|
|
1828
|
+
font-weight: 600;
|
|
1829
|
+
}
|
|
1830
|
+
.pvd-tabbtn span { writing-mode: vertical-rl; }
|
|
1831
|
+
.pvd-tabbtn:hover { color: var(--sg-fg, #0f172a); background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
1832
|
+
.pvd-tabbtn.is-active {
|
|
1833
|
+
color: var(--sg-accent, #2563eb);
|
|
1834
|
+
border-left-color: var(--sg-accent, #2563eb);
|
|
1835
|
+
background: var(--sg-header-bg, #f8fafc);
|
|
1836
|
+
}
|
|
1837
|
+
.pvd-tabbtn.is-active .pvd-ic { color: var(--sg-accent, #2563eb); }
|
|
1838
|
+
|
|
1839
|
+
/* ---- Filters tab (set filters) ---- */
|
|
1840
|
+
.pvd-filters-tab { display: flex; flex-direction: column; gap: 8px; padding: 10px; }
|
|
1841
|
+
.pvd-filt-card {
|
|
1842
|
+
border: 1px solid var(--sg-border, #e2e8f0);
|
|
1843
|
+
border-radius: 8px;
|
|
1844
|
+
background: var(--sg-bg, #fff);
|
|
1845
|
+
overflow: hidden;
|
|
1846
|
+
}
|
|
1847
|
+
.pvd-filt-head {
|
|
1848
|
+
display: flex; align-items: center;
|
|
1849
|
+
border-bottom: 1px solid var(--sg-border, #e2e8f0);
|
|
1850
|
+
}
|
|
1851
|
+
.pvd-filt-title {
|
|
1852
|
+
flex: 1; min-width: 0;
|
|
1853
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
1854
|
+
padding: 7px 8px;
|
|
1855
|
+
border: 0; background: transparent;
|
|
1856
|
+
font-size: 12.5px; font-weight: 600; color: var(--sg-fg, #0f172a);
|
|
1857
|
+
cursor: pointer; text-align: left;
|
|
1858
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1859
|
+
}
|
|
1860
|
+
.pvd-filt-twisty { display: inline-flex; color: var(--sg-muted, #64748b); transition: transform 100ms ease; }
|
|
1861
|
+
.pvd-filt-twisty:not(.is-open) { transform: rotate(-90deg); }
|
|
1862
|
+
.pvd-filt-x {
|
|
1863
|
+
border: 0; background: transparent; color: var(--sg-muted, #94a3b8);
|
|
1864
|
+
cursor: pointer; font-size: 15px; line-height: 1; padding: 4px 9px;
|
|
1865
|
+
}
|
|
1866
|
+
.pvd-filt-x:hover { color: #ef4444; }
|
|
1867
|
+
.pvd-filt-search {
|
|
1868
|
+
width: 100%; box-sizing: border-box;
|
|
1869
|
+
border: 0; border-bottom: 1px solid var(--sg-border, #e2e8f0);
|
|
1870
|
+
padding: 7px 9px; font-size: 12px; background: transparent; color: var(--sg-fg, #0f172a);
|
|
1871
|
+
outline: none;
|
|
1872
|
+
}
|
|
1873
|
+
.pvd-filt-list { max-height: 220px; overflow-y: auto; }
|
|
1874
|
+
.pvd-filt-opt {
|
|
1875
|
+
display: flex; align-items: center; gap: 8px;
|
|
1876
|
+
padding: 4px 10px; font-size: 12.5px; color: var(--sg-fg, #0f172a); cursor: pointer;
|
|
1877
|
+
}
|
|
1878
|
+
.pvd-filt-opt:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
1879
|
+
.pvd-filt-opt span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1880
|
+
.pvd-filt-opt input { accent-color: var(--sg-accent, #2563eb); flex: none; }
|
|
1881
|
+
.pvd-filt-all { font-weight: 600; border-bottom: 1px solid var(--sg-border, #e2e8f0); }
|
|
1882
|
+
|
|
1883
|
+
.pvd-addfilter { position: relative; }
|
|
1884
|
+
.pvd-addfilter-btn {
|
|
1885
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
1886
|
+
width: 100%; box-sizing: border-box;
|
|
1887
|
+
padding: 8px 10px;
|
|
1888
|
+
border: 1px solid var(--sg-border, #cbd5e1); border-radius: 8px;
|
|
1889
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
1890
|
+
font-size: 12.5px; font-weight: 600; cursor: pointer;
|
|
1891
|
+
}
|
|
1892
|
+
.pvd-addfilter-btn:hover { border-color: var(--sg-accent, #2563eb); background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
1893
|
+
.pvd-addfilter-btn .pvd-ic { color: var(--sg-muted, #64748b); }
|
|
1894
|
+
.pvd-addfilter-menu {
|
|
1895
|
+
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 60;
|
|
1896
|
+
background: var(--sg-bg, #fff);
|
|
1897
|
+
border: 1px solid var(--sg-border, #cbd5e1); border-radius: 8px;
|
|
1898
|
+
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
|
|
1899
|
+
overflow: hidden;
|
|
1900
|
+
}
|
|
1901
|
+
.pvd-addfilter-searchwrap {
|
|
1902
|
+
display: flex; align-items: center; gap: 6px;
|
|
1903
|
+
padding: 7px 9px; border-bottom: 1px solid var(--sg-border, #e2e8f0);
|
|
1904
|
+
color: var(--sg-muted, #94a3b8);
|
|
1905
|
+
}
|
|
1906
|
+
.pvd-addfilter-search { flex: 1; border: 0; outline: none; background: transparent; color: var(--sg-fg, #0f172a); font-size: 12px; }
|
|
1907
|
+
.pvd-addfilter-list { max-height: 240px; overflow-y: auto; padding: 4px 0; }
|
|
1908
|
+
.pvd-addfilter-item {
|
|
1909
|
+
display: block; width: 100%; text-align: left;
|
|
1910
|
+
border: 0; background: transparent; color: var(--sg-fg, #0f172a);
|
|
1911
|
+
padding: 6px 12px; font-size: 12.5px; cursor: pointer;
|
|
1912
|
+
}
|
|
1913
|
+
.pvd-addfilter-item:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
1914
|
+
.pvd-panel-rail {
|
|
1915
|
+
display: flex;
|
|
1916
|
+
flex-direction: column;
|
|
1917
|
+
flex: 1 1 auto;
|
|
1918
|
+
min-height: 120px;
|
|
1919
|
+
border-bottom: 1px solid var(--sg-border, #e2e8f0);
|
|
1920
|
+
}
|
|
1921
|
+
.pvd-panel-rail .pvd-search {
|
|
1922
|
+
background: var(--sg-input-bg, var(--sg-bg, #fff));
|
|
1923
|
+
border: 1px solid var(--sg-input-border, var(--sg-border, #e2e8f0));
|
|
1924
|
+
border-radius: var(--sg-radius, 6px);
|
|
1925
|
+
margin: 10px 10px 6px;
|
|
1926
|
+
padding: 6px 9px;
|
|
1927
|
+
}
|
|
1928
|
+
.pvd-panel-rail .pvd-fieldlist { padding-bottom: 8px; }
|
|
1929
|
+
.pvd-wells--vertical {
|
|
1930
|
+
display: flex;
|
|
1931
|
+
flex-direction: column;
|
|
1932
|
+
gap: 8px;
|
|
1933
|
+
padding: 10px;
|
|
1934
|
+
border-bottom: 0;
|
|
1935
|
+
flex: 0 0 auto;
|
|
1936
|
+
}
|
|
1937
|
+
.pvd-wells--vertical .pvd-well { min-height: 56px; background: var(--sg-bg, #fff); }
|
|
1938
|
+
|
|
1939
|
+
/* ---- Pivot Mode toggle ------------------------------------------- */
|
|
1940
|
+
.pvd-topbar {
|
|
1941
|
+
padding: 8px 10px;
|
|
1942
|
+
border-bottom: 1px solid var(--sg-border, #e2e8f0);
|
|
1943
|
+
background: var(--sg-header-bg, #f8fafc);
|
|
1944
|
+
}
|
|
1945
|
+
.pvd-panel > .pvd-pivot-toggle {
|
|
1946
|
+
padding: 12px;
|
|
1947
|
+
border-bottom: 1px solid var(--sg-border, #e2e8f0);
|
|
1948
|
+
}
|
|
1949
|
+
.pvd-pivot-toggle {
|
|
1950
|
+
display: flex;
|
|
1951
|
+
align-items: center;
|
|
1952
|
+
gap: 8px;
|
|
1953
|
+
cursor: pointer;
|
|
1954
|
+
user-select: none;
|
|
1955
|
+
}
|
|
1956
|
+
.pvd-pivot-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
1957
|
+
.pvd-switch {
|
|
1958
|
+
width: 34px; height: 18px; border-radius: 999px; flex-shrink: 0;
|
|
1959
|
+
background: var(--sg-border, #cbd5e1);
|
|
1960
|
+
position: relative; transition: background 120ms ease;
|
|
1961
|
+
}
|
|
1962
|
+
.pvd-switch.on { background: var(--sg-accent, #2563eb); }
|
|
1963
|
+
.pvd-switch-knob {
|
|
1964
|
+
position: absolute; top: 2px; left: 2px;
|
|
1965
|
+
width: 14px; height: 14px; border-radius: 999px; background: #fff;
|
|
1966
|
+
transition: transform 120ms ease;
|
|
1967
|
+
}
|
|
1968
|
+
.pvd-switch.on .pvd-switch-knob { transform: translateX(16px); }
|
|
1969
|
+
.pvd-pivot-icon { display: inline-flex; align-items: center; color: var(--sg-accent, #2563eb); }
|
|
1970
|
+
.pvd-pivot-label { font-size: 13px; font-weight: 600; color: var(--sg-fg, #0f172a); }
|
|
1971
|
+
|
|
1972
|
+
/* ---- Right-click context menu panel ------------------------------ */
|
|
1973
|
+
:global(.pvd-ctx) {
|
|
1974
|
+
z-index: 2147483646;
|
|
1975
|
+
min-width: 200px;
|
|
1976
|
+
background: var(--sg-bg, #fff);
|
|
1977
|
+
color: var(--sg-fg, #0f172a);
|
|
1978
|
+
border: 1px solid var(--sg-border, #e2e8f0);
|
|
1979
|
+
border-radius: 10px;
|
|
1980
|
+
box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35);
|
|
1981
|
+
font-size: 13px;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
@media (max-width: 900px) {
|
|
1985
|
+
.pvd-body[data-panel='right'] { flex-direction: column; }
|
|
1986
|
+
.pvd-panel { width: auto !important; border-left: 0; border-top: 1px solid var(--sg-border, #e2e8f0); }
|
|
1987
|
+
.pvd-wells--vertical { flex-direction: row; flex-wrap: wrap; }
|
|
1988
|
+
.pvd-wells--vertical .pvd-well { flex: 1 1 40%; }
|
|
1989
|
+
}
|
|
1990
|
+
</style>
|