@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.
Files changed (50) hide show
  1. package/README.md +18 -6
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +14025 -6836
  3. package/dist/node/studio.js +7889 -2460
  4. package/package.json +9 -4
  5. package/src/SvGridMasterDetail.svelte +24 -3
  6. package/src/SvGridScheduler.svelte +4410 -0
  7. package/src/SvPivotDesigner.svelte +1990 -1045
  8. package/src/SvSchemaChart.svelte +10 -9
  9. package/src/ai.test.ts +522 -522
  10. package/src/ai.ts +202 -2
  11. package/src/index.ts +409 -384
  12. package/src/install.ts +10 -0
  13. package/src/pivot-chart.test.ts +86 -0
  14. package/src/pivot-chart.ts +112 -0
  15. package/src/scheduler.ts +37 -0
  16. package/src/scheduling.test.ts +194 -0
  17. package/src/scheduling.ts +293 -0
  18. package/src/sources/filters.ts +6 -0
  19. package/src/studio/HANDLERS-DESIGN.md +142 -0
  20. package/src/studio/cli.ts +7 -2
  21. package/src/studio/emit-project.test.ts +1447 -13
  22. package/src/studio/emit-project.ts +3995 -1273
  23. package/src/studio/emit-schema.ts +146 -29
  24. package/src/studio/index.ts +320 -195
  25. package/src/studio/project.test.ts +370 -0
  26. package/src/studio/project.ts +1146 -26
  27. package/src/studio/sample-data.ts +4 -1
  28. package/src/studio/samples/ats.ts +2 -2
  29. package/src/studio/samples/clinic.ts +4 -2
  30. package/src/studio/samples/crm.ts +16 -8
  31. package/src/studio/samples/events.ts +4 -2
  32. package/src/studio/samples/fleet.ts +4 -2
  33. package/src/studio/samples/gym.ts +4 -2
  34. package/src/studio/samples/hr.ts +3 -1
  35. package/src/studio/samples/live-data.ts +308 -308
  36. package/src/studio/samples/projects.ts +2 -2
  37. package/src/studio/samples/restaurant.ts +4 -2
  38. package/src/studio/samples/samples.test.ts +13 -5
  39. package/src/studio/samples/shared.ts +346 -305
  40. package/src/studio/samples/support.ts +3 -1
  41. package/src/studio/scaffold.test.ts +15 -1
  42. package/src/studio/scaffold.ts +16 -0
  43. package/src/studio/themes.ts +7 -0
  44. package/src/studio/ui-components.ts +472 -0
  45. package/src/sveltekit/transport.test.ts +26 -0
  46. package/src/sveltekit/transport.ts +50 -5
  47. package/dist/designer/assets/index-Dp44bTid.js +0 -939
  48. package/dist/designer/assets/index-RJp6x8tw.css +0 -1
  49. package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
  50. 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
- tableFeatures,
25
- rowSortingFeature,
26
- columnFilteringFeature,
27
- renderSnippet,
28
- type ColumnDef,
29
- } from '@svgrid/grid'
30
- import { createPivotModel, filterCollapsedPivotRows, type PivotRow, type PivotAggregatorId } from './pivot'
31
- import {
32
- ALL_AGGREGATORS, AGG_LABEL, EMPTY_LAYOUT, defaultLayoutFor,
33
- type PivotField, type PivotLayout, type PivotPreset, type Well,
34
- } from './pivot-designer'
35
-
36
- type Props = {
37
- /** Flat input rows. */
38
- data: T[]
39
- /** All fields the user can pick from the rail. */
40
- fields: PivotField<T>[]
41
- /** The current pivot layout. Bindable so the consumer can persist it. */
42
- layout?: PivotLayout
43
- /** Fired when the user changes the layout (drag, drop, chip menu, …). */
44
- onLayoutChange?: (layout: PivotLayout) => void
45
-
46
- // ---- Optional features --------------------------------------------
47
- /** Saved layouts surfaced in the toolbar's Presets menu. */
48
- presets?: PivotPreset[]
49
- /** Aggregators offered in the Values chip menu. Default: all. */
50
- aggregators?: PivotAggregatorId[]
51
- /** Show the toolbar above the wells. Default true. */
52
- showToolbar?: boolean
53
- /** Show the left-rail field picker. Default true. */
54
- showFieldList?: boolean
55
- /** Show the Filters well. Default true. */
56
- showFiltersWell?: boolean
57
- /** Custom Export handler. When set, an Export button appears. */
58
- onExport?: (layout: PivotLayout, rows: PivotRow[]) => void
59
- /** Height of the inner pivot grid. Default '100%'. */
60
- gridHeight?: string | number
61
- /** Render the embedded grid? Set false to host it separately and
62
- * read `pivot` from the on:pivot event. Default true. */
63
- embedGrid?: boolean
64
- /** Fires whenever the underlying pivot model rebuilds. */
65
- onPivot?: (rows: PivotRow[], columns: ColumnDef<typeof features, PivotRow>[]) => void
66
- /** Allow row-level expand / collapse. When true, an expand chevron
67
- * appears in the label cell on every `group` row and clicking it
68
- * toggles which descendant rows are visible. The designer manages
69
- * the collapsed set internally. Default false. */
70
- expandable?: boolean
71
- /** Transform the generated column tree right before the grid renders.
72
- * The consumer can attach custom `cell:` / `header:` / `cellClass:`
73
- * properties, change widths, etc. Receives the full tree + the
74
- * current layout (so it can branch on which measures are present). */
75
- decorateColumns?: (
76
- cols: ColumnDef<typeof features, PivotRow>[],
77
- layout: PivotLayout,
78
- ) => ColumnDef<typeof features, PivotRow>[]
79
- /** Click handler forwarded to the embedded SvGrid - typically used
80
- * to drive a drill-through side panel. */
81
- onCellClick?: (ctx: { columnId: string; row: PivotRow; value: unknown }) => void
82
- }
83
- let {
84
- data,
85
- fields,
86
- layout = $bindable<PivotLayout>(defaultLayoutFor([])),
87
- onLayoutChange,
88
- presets,
89
- aggregators = ALL_AGGREGATORS,
90
- showToolbar = true,
91
- showFieldList = true,
92
- showFiltersWell = true,
93
- onExport,
94
- gridHeight = '100%',
95
- embedGrid = true,
96
- onPivot,
97
- expandable = false,
98
- decorateColumns,
99
- onCellClick,
100
- }: Props = $props()
101
-
102
- // Default the layout once on mount if the consumer passed nothing
103
- // meaningful. We seed inside $effect.pre so $bindable picks up the
104
- // mutation BEFORE the first render reads it.
105
- $effect.pre(() => {
106
- if (!layout || (!layout.rows.length && !layout.cols.length && !layout.values.length && !layout.filters.length)) {
107
- layout = defaultLayoutFor(fields)
108
- }
109
- })
110
-
111
- const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
112
- const uid = `pvd-${Math.random().toString(36).slice(2, 8)}`
113
-
114
- // Lookup helpers -----------------------------------------------------
115
- const fieldsByName = $derived(new Map(fields.map((f) => [f.field, f])))
116
- /** Field is currently "in use" - on any well - so the picker can grey it out. */
117
- function isFieldInLayout(field: string): boolean {
118
- return layout.rows.includes(field) ||
119
- layout.cols.includes(field) ||
120
- layout.values.some((v) => v.field === field) ||
121
- layout.filters.some((f) => f.field === field)
122
- }
123
-
124
- // ---- Search + grouped picker --------------------------------------
125
- let search = $state('')
126
- const filteredFields = $derived(
127
- fields.filter((f) => !search.trim() || f.label.toLowerCase().includes(search.trim().toLowerCase())),
128
- )
129
- const groupedFields = $derived.by(() => {
130
- const groups = new Map<string, PivotField<T>[]>()
131
- for (const f of filteredFields) {
132
- const key = f.group ?? (f.kind === 'dimension' ? 'Dimensions' : 'Measures')
133
- const arr = groups.get(key) ?? []
134
- arr.push(f); groups.set(key, arr)
135
- }
136
- return [...groups.entries()]
137
- })
138
-
139
- // ---- Mutation helpers ---------------------------------------------
140
- function emit(next: PivotLayout) {
141
- layout = next
142
- onLayoutChange?.(next)
143
- }
144
- function defaultWellFor(field: PivotField<T>): Well {
145
- return field.kind === 'measure' ? 'values' : 'rows'
146
- }
147
- /** Add a field to a well at `index` (or end). For rows / cols / filters a
148
- * field can appear at most ONCE - dragging it in moves it. For values
149
- * the same field with the SAME aggregator can only appear once, but
150
- * the same field with DIFFERENT aggregators is valid (e.g. spend/sum
151
- * + spend/avg in a scorecard). `sourceIndex` is the chip's position
152
- * in `values` when dragging within that well, used to dedup the move. */
153
- function addToWell(field: string, well: Well, index = Infinity, sourceIndex?: number) {
154
- const f = fieldsByName.get(field)
155
- if (!f) return
156
- const next: PivotLayout = {
157
- ...layout,
158
- rows: layout.rows.filter((x) => x !== field),
159
- cols: layout.cols.filter((x) => x !== field),
160
- // For values: only strip if we're MOVING a specific chip out of the
161
- // well. Don't strip every chip with the same field (that broke
162
- // "spend/sum + spend/avg" presets with a duplicate-key error).
163
- values: sourceIndex !== undefined
164
- ? layout.values.filter((_, i) => i !== sourceIndex)
165
- : layout.values.slice(),
166
- filters: layout.filters.filter((v) => v.field !== field),
167
- }
168
- if (well === 'rows' || well === 'cols') {
169
- const arr = next[well]
170
- arr.splice(Math.min(arr.length, index), 0, field)
171
- } else if (well === 'values') {
172
- const agg = f.defaultAgg ?? 'sum' as PivotAggregatorId
173
- // Skip if (field, agg) pair already present after the strip.
174
- if (next.values.some((v) => v.field === field && v.agg === agg)) {
175
- emit(next); return
176
- }
177
- const chip = { field, agg, label: f.label, format: f.format }
178
- next.values.splice(Math.min(next.values.length, index), 0, chip)
179
- } else if (well === 'filters') {
180
- next.filters.splice(Math.min(next.filters.length, index), 0, { field, allowed: null })
181
- }
182
- emit(next)
183
- }
184
- function removeFromWell(field: string, well: Well, valueIndex?: number) {
185
- const next: PivotLayout = { ...layout }
186
- if (well === 'rows') next.rows = layout.rows.filter((x) => x !== field)
187
- else if (well === 'cols') next.cols = layout.cols.filter((x) => x !== field)
188
- else if (well === 'values') {
189
- // Remove a SPECIFIC chip by its index, since the same field can
190
- // appear multiple times with different aggregators.
191
- next.values = valueIndex !== undefined
192
- ? layout.values.filter((_, i) => i !== valueIndex)
193
- : layout.values.filter((v) => v.field !== field)
194
- }
195
- else if (well === 'filters') next.filters = layout.filters.filter((v) => v.field !== field)
196
- emit(next)
197
- }
198
- function toggleFieldDefault(field: string) {
199
- const f = fieldsByName.get(field); if (!f) return
200
- if (isFieldInLayout(field)) {
201
- // Remove from every well (all chips for this field, even multiple
202
- // value chips with different aggregators).
203
- emit({
204
- ...layout,
205
- rows: layout.rows.filter((x) => x !== field),
206
- cols: layout.cols.filter((x) => x !== field),
207
- values: layout.values.filter((v) => v.field !== field),
208
- filters: layout.filters.filter((v) => v.field !== field),
209
- })
210
- } else {
211
- addToWell(field, defaultWellFor(f))
212
- }
213
- }
214
- /** Update the aggregator of the chip at the given index. If the change
215
- * would produce a duplicate (field, agg) pair, the chip is removed
216
- * instead of creating a key collision. */
217
- function setAggregatorAt(index: number, agg: PivotAggregatorId) {
218
- const chip = layout.values[index]
219
- if (!chip) return
220
- const field = chip.field
221
- const dup = layout.values.some((v, i) => i !== index && v.field === field && v.agg === agg)
222
- const label = AGG_LABEL[agg] + ' of ' + (fieldsByName.get(field)?.label ?? field)
223
- emit({
224
- ...layout,
225
- values: dup
226
- ? layout.values.filter((_, i) => i !== index)
227
- : layout.values.map((v, i) => (i === index ? { ...v, agg, label } : v)),
228
- })
229
- }
230
- function setFilterAllowed(field: string, allowed: string[] | null) {
231
- emit({
232
- ...layout,
233
- filters: layout.filters.map((f) => (f.field === field ? { ...f, allowed } : f)),
234
- })
235
- }
236
- function toggleHideSubtotals() {
237
- emit({ ...layout, hideSubtotals: !layout.hideSubtotals })
238
- }
239
- function toggleHideGrandTotals() {
240
- emit({ ...layout, hideGrandTotals: !layout.hideGrandTotals })
241
- }
242
- function reset() {
243
- emit(defaultLayoutFor(fields))
244
- }
245
- function loadPreset(p: PivotPreset) {
246
- emit(structuredClone(p.layout) as PivotLayout)
247
- }
248
-
249
- // ---- Drag-and-drop ------------------------------------------------
250
- /** What's being dragged: field id + the well it came from. `dragIndex`
251
- * is the source position in the values well (only set when dragging a
252
- * value chip; other wells have at most one chip per field so they
253
- * don't need an index). */
254
- let dragField = $state<string | null>(null)
255
- let dragFrom = $state<Well | 'rail' | null>(null)
256
- let dragIndex = $state<number | undefined>(undefined)
257
- let dragOver = $state<Well | null>(null)
258
- function onDragStart(e: DragEvent, field: string, from: Well | 'rail', index?: number) {
259
- dragField = field
260
- dragFrom = from
261
- dragIndex = index
262
- if (e.dataTransfer) {
263
- e.dataTransfer.effectAllowed = 'move'
264
- e.dataTransfer.setData('text/plain', field)
265
- }
266
- }
267
- function onDragOver(e: DragEvent, well: Well) {
268
- e.preventDefault()
269
- if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
270
- dragOver = well
271
- }
272
- function onDragLeave() { dragOver = null }
273
- function onDrop(e: DragEvent, well: Well) {
274
- e.preventDefault()
275
- dragOver = null
276
- const field = dragField || e.dataTransfer?.getData('text/plain') || ''
277
- if (!field) return
278
- // Pass the source index only when dragging FROM the values well, so
279
- // a same-field move (e.g. reorder spend/avg) removes the right chip.
280
- const srcIdx = dragFrom === 'values' ? dragIndex : undefined
281
- addToWell(field, well, Infinity, srcIdx)
282
- dragField = null
283
- dragFrom = null
284
- dragIndex = undefined
285
- }
286
-
287
- // ---- Apply filters BEFORE pivot ------------------------------------
288
- // The pivot model itself has no filtering, but the well clearly should:
289
- // a Filters chip with `allowed` restricts which rows reach the model.
290
- const filteredData = $derived.by(() => {
291
- if (!layout.filters.length) return data
292
- return data.filter((row) => {
293
- for (const f of layout.filters) {
294
- if (f.allowed == null) continue
295
- const v = row[f.field]
296
- if (!f.allowed.includes(String(v))) return false
297
- }
298
- return true
299
- })
300
- })
301
-
302
- // ---- Build the pivot model ----------------------------------------
303
- // Whenever the layout or data changes, re-run the pivot. Empty layouts
304
- // are tolerated: the grid will show a friendly empty state.
305
- const pivot = $derived.by(() => {
306
- if (!layout.values.length && !layout.rows.length && !layout.cols.length) return null
307
- if (!layout.values.length) return null
308
- return createPivotModel(filteredData, {
309
- rows: layout.rows as Array<keyof T & string>,
310
- cols: layout.cols as Array<keyof T & string>,
311
- values: layout.values.map((v) => ({
312
- field: v.field as keyof T & string,
313
- agg: v.agg,
314
- label: v.label,
315
- format: v.format,
316
- })),
317
- grandTotalRow: !layout.hideGrandTotals,
318
- grandTotalCol: !layout.hideGrandTotals,
319
- rowSubtotals: !layout.hideSubtotals,
320
- })
321
- })
322
- $effect(() => {
323
- if (pivot) onPivot?.(pivot.rows, pivot.columns)
324
- })
325
-
326
- // ---- Expand / collapse (when `expandable` is on) ------------------
327
- // We hold a `collapsed` set of pivot row ids; descendants of a
328
- // collapsed group are filtered out via the model helper. Reset to
329
- // fully-expanded whenever the source pivot rebuilds with different
330
- // group ids - otherwise stale ids would linger across layout changes.
331
- let collapsed = $state<Set<string>>(new Set())
332
- let lastPivotKey = $state('')
333
- $effect(() => {
334
- if (!pivot) return
335
- const key = pivot.rows.map((r) => r.__pivotId).join('|')
336
- if (key !== lastPivotKey) {
337
- lastPivotKey = key
338
- // Keep ids still present, drop the rest.
339
- const present = new Set(pivot.rows.map((r) => r.__pivotId))
340
- const next = new Set<string>()
341
- for (const id of collapsed) if (present.has(id)) next.add(id)
342
- if (next.size !== collapsed.size) collapsed = next
343
- }
344
- })
345
- function toggleRow(id: string) {
346
- const next = new Set(collapsed)
347
- if (next.has(id)) next.delete(id); else next.add(id)
348
- collapsed = next
349
- }
350
- function expandAll() { collapsed = new Set() }
351
- function collapseAll() {
352
- if (!pivot) return
353
- const next = new Set<string>()
354
- for (const r of pivot.rows) if (r.__pivotExpandable) next.add(r.__pivotId)
355
- collapsed = next
356
- }
357
- /** Rows the grid should actually render, honouring the collapsed set. */
358
- const visibleRows = $derived.by(() => {
359
- if (!pivot) return [] as PivotRow[]
360
- if (!expandable || collapsed.size === 0) return pivot.rows
361
- const expanded = new Set<string>()
362
- for (const r of pivot.rows) {
363
- if (r.__pivotExpandable && !collapsed.has(r.__pivotId)) expanded.add(r.__pivotId)
364
- }
365
- return filterCollapsedPivotRows(pivot.rows, expanded)
366
- })
367
-
368
- // ---- Column tree with optional decoration -------------------------
369
- // When `expandable`, the first (label) column gets a built-in chevron
370
- // renderer. The consumer's `decorateColumns` runs LAST so it can
371
- // override even the chevron behaviour if it wants something custom.
372
- const finalColumns = $derived.by(() => {
373
- if (!pivot) return [] as ColumnDef<typeof features, PivotRow>[]
374
- let cols = pivot.columns
375
- // The engine builds the row-header column header from raw field names
376
- // (e.g. "region / country") which renders lowercase. Replace it with
377
- // the chained `field.label` from the picker so the rendered header
378
- // matches the chip text in the wells (e.g. "Region / Country").
379
- if (cols.length && layout.rows.length) {
380
- const rowLabel = layout.rows
381
- .map((f) => fieldsByName.get(f)?.label ?? f)
382
- .join(' / ')
383
- cols = [{ ...cols[0]!, header: rowLabel }, ...cols.slice(1)]
384
- }
385
- if (expandable) {
386
- cols = cols.map((c, i) => {
387
- if (i !== 0) return c
388
- return {
389
- ...c,
390
- cell: (ctx) => renderSnippet(ChevronLabelCell, {
391
- row: ctx.row.original,
392
- collapsed: collapsed.has(ctx.row.original.__pivotId),
393
- onToggle: () => toggleRow(ctx.row.original.__pivotId),
394
- }),
395
- }
396
- })
397
- }
398
- return decorateColumns ? decorateColumns(cols, layout) : cols
399
- })
400
-
401
- // ---- Filter chip menu (which values pass) --------------------------
402
- /** Distinct values for a field, computed from the FULL data (so the
403
- * menu shows every option, not just those the current filters
404
- * already pass). */
405
- function distinctValuesFor(field: string): string[] {
406
- const set = new Set<string>()
407
- for (const row of data) set.add(String(row[field]))
408
- return [...set].sort((a, b) => a.localeCompare(b))
409
- }
410
-
411
- // ---- Active menus (open one chip at a time) ----------------------
412
- // Filter menus key by `field` (one filter chip per field).
413
- // Agg menus key by `index` (a field can have multiple value chips at
414
- // different aggregators; index is the only stable identity).
415
- let openMenu = $state<
416
- | { kind: 'filter'; field: string }
417
- | { kind: 'agg'; index: number }
418
- | null
419
- >(null)
420
- function toggleFilterMenu(field: string) {
421
- if (openMenu?.kind === 'filter' && openMenu.field === field) openMenu = null
422
- else openMenu = { kind: 'filter', field }
423
- }
424
- function toggleAggMenu(index: number) {
425
- if (openMenu?.kind === 'agg' && openMenu.index === index) openMenu = null
426
- else openMenu = { kind: 'agg', index }
427
- }
428
- function closeMenu() { openMenu = null }
429
- // Close on outside click.
430
- $effect(() => {
431
- if (!openMenu) return
432
- function on(e: MouseEvent) {
433
- const t = e.target as HTMLElement | null
434
- if (t && t.closest(`[data-pvd-menu="${uid}"]`)) return
435
- openMenu = null
436
- }
437
- window.addEventListener('mousedown', on)
438
- return () => window.removeEventListener('mousedown', on)
439
- })
440
-
441
- // ---- Presets menu ------------------------------------------------
442
- let presetsOpen = $state(false)
443
- function togglePresets() { presetsOpen = !presetsOpen }
444
- $effect(() => {
445
- if (!presetsOpen) return
446
- function on(e: MouseEvent) {
447
- const t = e.target as HTMLElement | null
448
- if (t && t.closest(`[data-pvd-presets="${uid}"]`)) return
449
- presetsOpen = false
450
- }
451
- window.addEventListener('mousedown', on)
452
- return () => window.removeEventListener('mousedown', on)
453
- })
454
- </script>
455
-
456
- <section class="pvd" data-uid={uid}>
457
- {#if showToolbar}
458
- <header class="pvd-toolbar">
459
- <strong class="pvd-title">Pivot designer</strong>
460
- <button type="button" class="pvd-btn" onclick={reset} title="Restore the default layout">↺ Reset</button>
461
- {#if presets?.length}
462
- <div class="pvd-presets" data-pvd-presets={uid}>
463
- <button type="button" class="pvd-btn" onclick={togglePresets}>Presets ▾</button>
464
- {#if presetsOpen}
465
- <div class="pvd-popover">
466
- {#each presets as p (p.name)}
467
- <button type="button" class="pvd-popover-item" onclick={() => { loadPreset(p); presetsOpen = false }}>{p.name}</button>
468
- {/each}
469
- </div>
470
- {/if}
471
- </div>
472
- {/if}
473
- <label class="pvd-toggle">
474
- <input type="checkbox" checked={!layout.hideSubtotals} onchange={toggleHideSubtotals} /> Subtotals
475
- </label>
476
- <label class="pvd-toggle">
477
- <input type="checkbox" checked={!layout.hideGrandTotals} onchange={toggleHideGrandTotals} /> Grand totals
478
- </label>
479
- <div class="pvd-spacer"></div>
480
- {#if onExport && pivot}
481
- <button type="button" class="pvd-btn pvd-btn-primary" onclick={() => onExport!(layout, pivot!.rows)}>Export…</button>
482
- {/if}
483
- </header>
484
- {/if}
485
-
486
- <div class="pvd-body" class:no-rail={!showFieldList}>
487
- {#if showFieldList}
488
- <aside class="pvd-rail" aria-label="Available fields">
489
- <input
490
- type="search"
491
- class="pvd-search"
492
- placeholder="Search fields…"
493
- bind:value={search}
494
- />
495
- <div class="pvd-fieldlist">
496
- {#each groupedFields as [groupName, items] (groupName)}
497
- <div class="pvd-group-head">{groupName}</div>
498
- {#each items as f (f.field)}
499
- {@const inUse = isFieldInLayout(f.field)}
500
- <div
501
- class="pvd-field"
502
- class:in-use={inUse}
503
- draggable="true"
504
- ondragstart={(e) => onDragStart(e, f.field, 'rail')}
505
- role="button"
506
- tabindex="0"
507
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleFieldDefault(f.field) } }}
508
- >
509
- <input type="checkbox" checked={inUse} onchange={() => toggleFieldDefault(f.field)} aria-label={`Toggle ${f.label}`} />
510
- <span class="pvd-field-label">{f.label}</span>
511
- <span class="pvd-field-kind">{f.kind === 'dimension' ? 'D' : 'Σ'}</span>
512
- </div>
513
- {/each}
514
- {/each}
515
- {#if !filteredFields.length}
516
- <div class="pvd-empty">No fields match "{search}"</div>
517
- {/if}
518
- </div>
519
- </aside>
520
- {/if}
521
-
522
- <div class="pvd-main">
523
- <div class="pvd-wells" class:two={!showFiltersWell}>
524
- <!-- Filters -->
525
- {#if showFiltersWell}
526
- <div class="pvd-well"
527
- class:drag-over={dragOver === 'filters'}
528
- ondragover={(e) => onDragOver(e, 'filters')}
529
- ondragleave={onDragLeave}
530
- ondrop={(e) => onDrop(e, 'filters')}>
531
- <div class="pvd-well-head">Filters</div>
532
- <div class="pvd-well-body">
533
- {#each layout.filters as f (f.field)}
534
- {@const fd = fieldsByName.get(f.field)}
535
- <div class="pvd-chip" draggable="true" ondragstart={(e) => onDragStart(e, f.field, 'filters')}>
536
- <button type="button" class="pvd-chip-label" onclick={() => toggleFilterMenu(f.field)}>
537
- {fd?.label ?? f.field}{f.allowed ? ` (${f.allowed.length})` : ''}
538
- </button>
539
- <button type="button" class="pvd-chip-x" onclick={() => removeFromWell(f.field, 'filters')} aria-label="Remove">×</button>
540
- {#if openMenu?.kind === 'filter' && openMenu?.field === f.field}
541
- {@const all = distinctValuesFor(f.field)}
542
- <div class="pvd-popover pvd-popover-filter" data-pvd-menu={uid}>
543
- <div class="pvd-popover-head">
544
- <button type="button" class="pvd-popover-mini" onclick={() => setFilterAllowed(f.field, null)}>All</button>
545
- <button type="button" class="pvd-popover-mini" onclick={() => setFilterAllowed(f.field, [])}>None</button>
546
- </div>
547
- <div class="pvd-popover-list">
548
- {#each all as v (v)}
549
- {@const checked = f.allowed == null || f.allowed.includes(v)}
550
- <label class="pvd-popover-item pvd-popover-check">
551
- <input type="checkbox" checked={checked}
552
- onchange={() => {
553
- const cur = f.allowed ?? all.slice()
554
- const next = cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v]
555
- setFilterAllowed(f.field, next.length === all.length ? null : next)
556
- }} />
557
- <span>{v}</span>
558
- </label>
559
- {/each}
560
- </div>
561
- </div>
562
- {/if}
563
- </div>
564
- {/each}
565
- {#if !layout.filters.length}<span class="pvd-well-hint">drop fields here to filter the source rows</span>{/if}
566
- </div>
567
- </div>
568
- {/if}
569
-
570
- <!-- Columns -->
571
- <div class="pvd-well"
572
- class:drag-over={dragOver === 'cols'}
573
- ondragover={(e) => onDragOver(e, 'cols')}
574
- ondragleave={onDragLeave}
575
- ondrop={(e) => onDrop(e, 'cols')}>
576
- <div class="pvd-well-head">Columns</div>
577
- <div class="pvd-well-body">
578
- {#each layout.cols as field (field)}
579
- {@const fd = fieldsByName.get(field)}
580
- <div class="pvd-chip" draggable="true" ondragstart={(e) => onDragStart(e, field, 'cols')}>
581
- <span class="pvd-chip-label">{fd?.label ?? field}</span>
582
- <button type="button" class="pvd-chip-x" onclick={() => removeFromWell(field, 'cols')} aria-label="Remove">×</button>
583
- </div>
584
- {/each}
585
- {#if !layout.cols.length}<span class="pvd-well-hint">drop dimensions to pivot along the column axis</span>{/if}
586
- </div>
587
- </div>
588
-
589
- <!-- Rows -->
590
- <div class="pvd-well"
591
- class:drag-over={dragOver === 'rows'}
592
- ondragover={(e) => onDragOver(e, 'rows')}
593
- ondragleave={onDragLeave}
594
- ondrop={(e) => onDrop(e, 'rows')}>
595
- <div class="pvd-well-head">Rows</div>
596
- <div class="pvd-well-body">
597
- {#each layout.rows as field (field)}
598
- {@const fd = fieldsByName.get(field)}
599
- <div class="pvd-chip" draggable="true" ondragstart={(e) => onDragStart(e, field, 'rows')}>
600
- <span class="pvd-chip-label">{fd?.label ?? field}</span>
601
- <button type="button" class="pvd-chip-x" onclick={() => removeFromWell(field, 'rows')} aria-label="Remove">×</button>
602
- </div>
603
- {/each}
604
- {#if !layout.rows.length}<span class="pvd-well-hint">drop dimensions to group rows</span>{/if}
605
- </div>
606
- </div>
607
-
608
- <!-- Values -->
609
- <div class="pvd-well"
610
- class:drag-over={dragOver === 'values'}
611
- ondragover={(e) => onDragOver(e, 'values')}
612
- ondragleave={onDragLeave}
613
- ondrop={(e) => onDrop(e, 'values')}>
614
- <div class="pvd-well-head">Values</div>
615
- <div class="pvd-well-body">
616
- {#each layout.values as v, vi (v.field + '|' + v.agg + '|' + vi)}
617
- {@const fd = fieldsByName.get(v.field)}
618
- <div class="pvd-chip pvd-chip-value" draggable="true" ondragstart={(e) => onDragStart(e, v.field, 'values', vi)}>
619
- <button type="button" class="pvd-chip-label" onclick={() => toggleAggMenu(vi)}>
620
- <span class="pvd-chip-agg">{AGG_LABEL[v.agg]}</span>
621
- <span class="pvd-chip-sep">·</span>
622
- <span>{fd?.label ?? v.field}</span>
623
- </button>
624
- <button type="button" class="pvd-chip-x" onclick={() => removeFromWell(v.field, 'values', vi)} aria-label="Remove">×</button>
625
- {#if openMenu?.kind === 'agg' && openMenu.index === vi}
626
- <div class="pvd-popover" data-pvd-menu={uid}>
627
- {#each aggregators as agg (agg)}
628
- <button
629
- type="button"
630
- class="pvd-popover-item"
631
- class:is-active={v.agg === agg}
632
- onclick={() => { setAggregatorAt(vi, agg); closeMenu() }}
633
- >
634
- {AGG_LABEL[agg]}
635
- </button>
636
- {/each}
637
- </div>
638
- {/if}
639
- </div>
640
- {/each}
641
- {#if !layout.values.length}<span class="pvd-well-hint">drop measures to aggregate</span>{/if}
642
- </div>
643
- </div>
644
- </div>
645
-
646
- {#if embedGrid}
647
- <div class="pvd-grid" style={`height:${typeof gridHeight === 'number' ? gridHeight + 'px' : gridHeight}`}>
648
- {#if pivot}
649
- <SvGrid
650
- data={visibleRows}
651
- columns={finalColumns}
652
- features={features}
653
- sortable
654
- filterable
655
- selectionMode="none"
656
- rowHeight={32}
657
- containerHeight="100%"
658
- fitColumns={true}
659
- enableRowSummaries={false}
660
- {onCellClick}
661
- />
662
- {:else}
663
- <div class="pvd-empty pvd-grid-empty">
664
- {#if !layout.values.length}
665
- Drop at least one <strong>measure</strong> into Values to render the pivot.
666
- {:else}
667
- No data.
668
- {/if}
669
- </div>
670
- {/if}
671
- </div>
672
- {/if}
673
- </div>
674
- </div>
675
- </section>
676
-
677
- <!-- Label cell renderer used when `expandable` is on. Indents by
678
- pivot depth and shows a clickable chevron for expandable rows
679
- (group rows that have descendants). -->
680
- {#snippet ChevronLabelCell({ row, collapsed: isCollapsed, onToggle }: { row: PivotRow; collapsed: boolean; onToggle: () => void })}
681
- <span class="pvd-label-cell" style={`padding-left:${row.__pivotDepth * 14}px`}>
682
- {#if row.__pivotExpandable}
683
- <button
684
- type="button"
685
- class="pvd-chev"
686
- class:is-collapsed={isCollapsed}
687
- onclick={(e) => { e.stopPropagation(); onToggle() }}
688
- aria-label={isCollapsed ? 'Expand' : 'Collapse'}
689
- >▾</button>
690
- {:else}
691
- <span class="pvd-chev pvd-chev-placeholder"></span>
692
- {/if}
693
- <span class="pvd-label-text" class:is-subtotal={row.__pivotKind === 'subtotal'} class:is-grand={row.__pivotKind === 'grandTotal'}>
694
- {row.__pivotLabel}
695
- </span>
696
- </span>
697
- {/snippet}
698
-
699
- <style>
700
- /* SvPivotDesigner styles -------------------------------------------
701
- All variables use the SvGrid token system (--sg-*) with safe
702
- fallbacks so the component theme-matches whatever grid skin the
703
- host page is using. */
704
- .pvd {
705
- display: flex;
706
- flex-direction: column;
707
- width: 100%;
708
- height: 100%;
709
- min-height: 0;
710
- color: var(--sg-fg, #0f172a);
711
- background: var(--sg-bg, #ffffff);
712
- border: 1px solid var(--sg-border, #e2e8f0);
713
- border-radius: 8px;
714
- overflow: hidden;
715
- font-family: inherit;
716
- }
717
- .pvd-toolbar {
718
- display: flex;
719
- align-items: center;
720
- gap: 8px;
721
- padding: 8px 12px;
722
- background: var(--sg-header-bg, #f8fafc);
723
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
724
- flex-shrink: 0;
725
- }
726
- .pvd-title {
727
- font-size: 13px;
728
- font-weight: 700;
729
- color: var(--sg-fg);
730
- margin-right: 8px;
731
- }
732
- .pvd-spacer { flex: 1; }
733
- .pvd-btn {
734
- border: 1px solid var(--sg-border, #cbd5e1);
735
- background: var(--sg-bg, #ffffff);
736
- color: var(--sg-fg, #1e293b);
737
- padding: 4px 10px;
738
- border-radius: 5px;
739
- font-size: 12px;
740
- font-weight: 600;
741
- cursor: pointer;
742
- transition: background 100ms ease, border-color 100ms ease;
743
- }
744
- .pvd-btn:hover { background: var(--sg-row-hover-bg, #f1f5f9); border-color: var(--sg-accent, #2563eb); }
745
- .pvd-btn-primary {
746
- background: var(--sg-accent, #2563eb);
747
- color: #fff;
748
- border-color: var(--sg-accent, #2563eb);
749
- }
750
- .pvd-btn-primary:hover { opacity: 0.9; }
751
- .pvd-toggle {
752
- display: inline-flex;
753
- align-items: center;
754
- gap: 4px;
755
- font-size: 12px;
756
- color: var(--sg-fg);
757
- cursor: pointer;
758
- user-select: none;
759
- }
760
- .pvd-toggle input { accent-color: var(--sg-accent, #2563eb); }
761
-
762
- .pvd-body {
763
- display: grid;
764
- grid-template-columns: 220px 1fr;
765
- gap: 0;
766
- flex: 1;
767
- min-height: 0;
768
- }
769
- .pvd-body.no-rail { grid-template-columns: 1fr; }
770
-
771
- /* Left rail */
772
- .pvd-rail {
773
- display: flex;
774
- flex-direction: column;
775
- border-right: 1px solid var(--sg-border, #e2e8f0);
776
- background: var(--sg-bg, #ffffff);
777
- min-height: 0;
778
- }
779
- .pvd-search {
780
- border: 0;
781
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
782
- padding: 8px 12px;
783
- font-size: 12px;
784
- background: transparent;
785
- color: var(--sg-fg);
786
- outline: none;
787
- }
788
- .pvd-search:focus { background: var(--sg-row-hover-bg, #f1f5f9); }
789
- .pvd-fieldlist {
790
- flex: 1;
791
- min-height: 0;
792
- overflow: auto;
793
- padding: 4px 0;
794
- }
795
- .pvd-group-head {
796
- padding: 8px 12px 4px;
797
- font-size: 10.5px;
798
- font-weight: 700;
799
- text-transform: uppercase;
800
- letter-spacing: 0.06em;
801
- color: var(--sg-muted, #64748b);
802
- }
803
- .pvd-field {
804
- display: grid;
805
- grid-template-columns: 16px 1fr auto;
806
- align-items: center;
807
- gap: 8px;
808
- padding: 5px 12px;
809
- font-size: 12px;
810
- cursor: grab;
811
- transition: background 80ms ease;
812
- }
813
- .pvd-field:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
814
- .pvd-field:active { cursor: grabbing; }
815
- .pvd-field.in-use .pvd-field-label { font-weight: 600; color: var(--sg-accent, #2563eb); }
816
- .pvd-field-label {
817
- overflow: hidden;
818
- text-overflow: ellipsis;
819
- white-space: nowrap;
820
- }
821
- .pvd-field-kind {
822
- font-family: ui-monospace, monospace;
823
- font-size: 10px;
824
- color: var(--sg-muted, #94a3b8);
825
- background: var(--sg-header-bg, #f1f5f9);
826
- padding: 1px 5px;
827
- border-radius: 3px;
828
- }
829
-
830
- /* Main area */
831
- .pvd-main {
832
- display: flex;
833
- flex-direction: column;
834
- min-height: 0;
835
- min-width: 0;
836
- }
837
- .pvd-wells {
838
- display: grid;
839
- grid-template-columns: repeat(4, 1fr);
840
- gap: 8px;
841
- padding: 8px;
842
- background: var(--sg-bg, #ffffff);
843
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
844
- }
845
- .pvd-wells.two { grid-template-columns: repeat(3, 1fr); }
846
-
847
- .pvd-well {
848
- display: flex;
849
- flex-direction: column;
850
- border: 1px dashed var(--sg-border, #cbd5e1);
851
- border-radius: 6px;
852
- background: var(--sg-bg, #ffffff);
853
- min-height: 64px;
854
- transition: border-color 100ms ease, background 100ms ease;
855
- }
856
- .pvd-well.drag-over {
857
- border-color: var(--sg-accent, #2563eb);
858
- border-style: solid;
859
- background: color-mix(in srgb, var(--sg-accent, #2563eb) 8%, var(--sg-bg, #ffffff));
860
- }
861
- .pvd-well-head {
862
- padding: 4px 8px 2px;
863
- font-size: 10.5px;
864
- font-weight: 700;
865
- text-transform: uppercase;
866
- letter-spacing: 0.06em;
867
- color: var(--sg-muted, #64748b);
868
- }
869
- .pvd-well-body {
870
- display: flex;
871
- flex-wrap: wrap;
872
- gap: 4px;
873
- padding: 4px 8px 8px;
874
- min-height: 36px;
875
- align-content: flex-start;
876
- }
877
- .pvd-well-hint {
878
- color: var(--sg-muted, #94a3b8);
879
- font-size: 11px;
880
- font-style: italic;
881
- padding: 4px 0;
882
- }
883
-
884
- .pvd-chip {
885
- position: relative;
886
- display: inline-flex;
887
- align-items: center;
888
- gap: 2px;
889
- background: var(--sg-header-bg, #f1f5f9);
890
- border: 1px solid var(--sg-border, #cbd5e1);
891
- border-radius: 5px;
892
- font-size: 12px;
893
- color: var(--sg-fg, #0f172a);
894
- cursor: grab;
895
- user-select: none;
896
- }
897
- .pvd-chip:active { cursor: grabbing; }
898
- .pvd-chip-value {
899
- background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, var(--sg-bg, #ffffff));
900
- border-color: var(--sg-accent, #2563eb);
901
- }
902
- .pvd-chip-label {
903
- border: 0;
904
- background: transparent;
905
- padding: 4px 8px;
906
- font: inherit;
907
- color: inherit;
908
- cursor: pointer;
909
- }
910
- .pvd-chip-agg {
911
- font-size: 10.5px;
912
- font-weight: 700;
913
- color: var(--sg-accent, #2563eb);
914
- text-transform: uppercase;
915
- letter-spacing: 0.04em;
916
- margin-right: 4px;
917
- }
918
- .pvd-chip-sep { color: var(--sg-muted, #94a3b8); margin-right: 4px; }
919
- .pvd-chip-x {
920
- border: 0;
921
- background: transparent;
922
- color: var(--sg-muted, #64748b);
923
- cursor: pointer;
924
- padding: 2px 6px 3px;
925
- font-size: 14px;
926
- line-height: 1;
927
- border-radius: 3px;
928
- }
929
- .pvd-chip-x:hover { color: #ef4444; background: rgba(239, 68, 68, 0.08); }
930
-
931
- /* Popovers (agg menu, filter menu, presets) */
932
- .pvd-popover, .pvd-presets {
933
- position: relative;
934
- }
935
- .pvd-popover {
936
- position: absolute;
937
- top: calc(100% + 4px);
938
- left: 0;
939
- z-index: 50;
940
- min-width: 160px;
941
- background: var(--sg-bg, #ffffff);
942
- border: 1px solid var(--sg-border, #cbd5e1);
943
- border-radius: 6px;
944
- box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
945
- padding: 4px;
946
- }
947
- .pvd-popover-filter { min-width: 200px; max-height: 280px; display: flex; flex-direction: column; }
948
- .pvd-popover-head {
949
- display: flex; gap: 4px;
950
- padding: 4px;
951
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
952
- }
953
- .pvd-popover-mini {
954
- flex: 1;
955
- border: 1px solid var(--sg-border, #cbd5e1);
956
- background: var(--sg-bg, #ffffff);
957
- color: var(--sg-fg);
958
- padding: 2px 6px;
959
- border-radius: 4px;
960
- font-size: 11px;
961
- cursor: pointer;
962
- }
963
- .pvd-popover-mini:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
964
- .pvd-popover-list { flex: 1; min-height: 0; overflow: auto; padding: 4px 0; }
965
- .pvd-popover-item {
966
- display: block;
967
- width: 100%;
968
- text-align: left;
969
- border: 0;
970
- background: transparent;
971
- padding: 5px 10px;
972
- font-size: 12px;
973
- color: var(--sg-fg);
974
- cursor: pointer;
975
- border-radius: 4px;
976
- }
977
- .pvd-popover-item:hover { background: var(--sg-row-hover-bg, #f1f5f9); }
978
- .pvd-popover-item.is-active { background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, transparent); color: var(--sg-accent, #2563eb); font-weight: 600; }
979
- .pvd-popover-check {
980
- display: flex; align-items: center; gap: 8px;
981
- cursor: pointer;
982
- }
983
- .pvd-popover-check input { accent-color: var(--sg-accent, #2563eb); }
984
-
985
- /* Embedded grid */
986
- .pvd-grid {
987
- flex: 1;
988
- min-height: 0;
989
- padding: 0;
990
- }
991
- .pvd-grid-empty {
992
- display: flex;
993
- align-items: center;
994
- justify-content: center;
995
- height: 100%;
996
- color: var(--sg-muted, #94a3b8);
997
- font-size: 13px;
998
- padding: 24px;
999
- text-align: center;
1000
- }
1001
-
1002
- .pvd-empty {
1003
- padding: 20px;
1004
- color: var(--sg-muted, #94a3b8);
1005
- font-size: 12px;
1006
- text-align: center;
1007
- }
1008
-
1009
- /* Built-in label cell renderer (when expandable is on) */
1010
- :global(.pvd-label-cell) {
1011
- display: inline-flex;
1012
- align-items: center;
1013
- gap: 4px;
1014
- line-height: 1;
1015
- }
1016
- :global(.pvd-chev) {
1017
- width: 14px;
1018
- height: 14px;
1019
- border: 0;
1020
- background: transparent;
1021
- color: var(--sg-muted, #64748b);
1022
- font-size: 10px;
1023
- line-height: 14px;
1024
- cursor: pointer;
1025
- border-radius: 3px;
1026
- padding: 0;
1027
- transition: transform 100ms ease, background 100ms ease;
1028
- }
1029
- :global(.pvd-chev:hover) { background: var(--sg-row-hover-bg, #f1f5f9); color: var(--sg-fg, #0f172a); }
1030
- :global(.pvd-chev.is-collapsed) { transform: rotate(-90deg); }
1031
- :global(.pvd-chev-placeholder) { cursor: default; visibility: hidden; }
1032
- :global(.pvd-label-text.is-subtotal) { font-weight: 700; }
1033
- :global(.pvd-label-text.is-grand) { font-weight: 800; color: var(--sg-accent, #2563eb); }
1034
-
1035
- /* Mobile: stack rail above main, wells in two columns. */
1036
- @media (max-width: 900px) {
1037
- .pvd-body { grid-template-columns: 1fr; }
1038
- .pvd-rail {
1039
- border-right: 0;
1040
- border-bottom: 1px solid var(--sg-border, #e2e8f0);
1041
- max-height: 200px;
1042
- }
1043
- .pvd-wells, .pvd-wells.two { grid-template-columns: repeat(2, 1fr); }
1044
- }
1045
- </style>
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>