@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.
- package/LICENSE +97 -35
- package/README.md +50 -18
- package/package.json +1 -1
- package/src/SvPivotDesigner.svelte +1045 -0
- package/src/ai.test.ts +1 -1
- package/src/ai.ts +6 -6
- package/src/export.ts +2 -2
- package/src/import.test.ts +1 -1
- package/src/import.ts +2 -2
- package/src/index.ts +17 -3
- package/src/install.ts +8 -8
- package/src/license.ts +9 -9
- package/src/pivot-designer.ts +101 -0
- package/src/pivot.test.ts +31 -3
- package/src/pivot.ts +39 -10
- package/src/print.ts +2 -2
- package/src/revoked.ts +3 -3
- package/src/upgrade-prompt.test.ts +1 -1
- package/src/upgrade-prompt.ts +5 -5
- package/src/watermark.test.ts +1 -1
- package/src/watermark.ts +3 -3
|
@@ -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>
|