@svgrid/enterprise 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1045 @@
1
+ <script lang="ts" module>
2
+ type PivotInputRow = Record<string, unknown>
3
+ </script>
4
+ <script lang="ts" generics="T extends PivotInputRow">
5
+ /**
6
+ * SvPivotDesigner - Excel-style pivot table designer.
7
+ *
8
+ * Self-contained, controlled component. The consumer holds the data
9
+ * + the field list + the layout state; everything else (drag-and-drop,
10
+ * chip menus, search, presets, the inline pivot grid) is built in.
11
+ *
12
+ * Minimal wire-up (in a .svelte file):
13
+ * const fields = [
14
+ * { field: 'region', label: 'Region', kind: 'dimension' },
15
+ * { field: 'quarter', label: 'Quarter', kind: 'dimension' },
16
+ * { field: 'amount', label: 'Revenue', kind: 'measure', defaultAgg: 'sum' },
17
+ * ]
18
+ * let layout = $state(defaultLayoutFor(fields))
19
+ *
20
+ * SvPivotDesigner {data} {fields} bind:layout
21
+ */
22
+ import {
23
+ SvGrid,
24
+ 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>