Pivot - Analysis workspace
Excel-style pivot analysis: left-rail field picker (search + checkboxes) feeding four wells (Rows / Columns / Data / Filters), live re-pivot on every layout change, click-to-cycle aggregator chips, data-bar Total Spend cells + amber Avg Rating strips, subtotal + grand-total row tints. (requires @svgrid/enterprise)
A live, editable Svelte 5 data grid example. Open the interactive demo or read the documentation.
What this example shows
A loyalty / membership analytics workspace built on `<SvPivotDesigner>` (see demo 168 for the canonical sales example). The component handles the whole authoring UX - field rail, four drop wells, drag-and-drop, chip menus, presets, totals toggles, and the inline pivot grid - so a real workspace is now ~80 lines of glue.
The seed dataset is a loyalty shape: 6 cities × 3 membership tiers × 8 hourly time buckets, with spend + rating measures and a few extra dimensions (gender, age band, discount) the user can pull into the layout. ~1800 rows pivots live without virtualisation.
Source code (166-pivot-analysis-workspace.svelte)
<script lang="ts">
/**
* 166. Pivot - Analysis workspace (Enterprise)
* --------------------------------------------
* A loyalty / membership analytics workspace built on `<SvPivotDesigner>`
* (see demo 168 for the canonical sales example). The component handles
* the whole authoring UX - field rail, four drop wells, drag-and-drop,
* chip menus, presets, totals toggles, and the inline pivot grid - so
* a real workspace is now ~80 lines of glue.
*
* The seed dataset is a loyalty shape: 6 cities × 3 membership tiers ×
* 8 hourly time buckets, with spend + rating measures and a few extra
* dimensions (gender, age band, discount) the user can pull into the
* layout. ~1800 rows pivots live without virtualisation.
*/
import {
SvPivotDesigner,
setLicenseKey,
type PivotField,
type PivotLayout,
type PivotPreset,
} from '@svgrid/enterprise'
setLicenseKey('SVENTERPRISE-DEV-DEMO')
// ---- Domain --------------------------------------------------------
type City = 'Chicago' | 'Houston' | 'Los Angeles' | 'Miami' | 'New York' | 'San Francisco'
type Membership = 'Bronze' | 'Silver' | 'Gold'
type Gender = 'M' | 'F' | 'X'
type AgeBand = '18-24' | '25-34' | '35-44' | '45-54' | '55+'
type Fact = {
age: AgeBand
time: string
city: City
gender: Gender
membership: Membership
spend: number
spendChange: number
rating: number
discount: 'Yes' | 'No'
}
const CITIES: readonly City[] = ['Chicago', 'Houston', 'Los Angeles', 'Miami', 'New York', 'San Francisco']
const MEMBERSHIPS: readonly Membership[] = ['Bronze', 'Silver', 'Gold']
const GENDERS: readonly Gender[] = ['M', 'F', 'X']
const AGES: readonly AgeBand[] = ['18-24', '25-34', '35-44', '45-54', '55+']
const TIMES: readonly string[] = Array.from({ length: 8 }, (_, i) => String(i).padStart(2, '0') + ':00')
const CITY_BASE: Record<City, number> = {
Chicago: 320, Houston: 240, 'Los Angeles': 480,
Miami: 220, 'New York': 580, 'San Francisco': 520,
}
const TIER_MULT: Record<Membership, number> = { Bronze: 1, Silver: 1.6, Gold: 2.4 }
// Deterministic PRNG so the same dataset renders across reloads.
let prng = 0x12345678
function rnd(): number { prng = (prng * 1664525 + 1013904223) >>> 0; return prng / 0xFFFFFFFF }
function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rnd() * arr.length)]! }
function seedFacts(): Fact[] {
const out: Fact[] = []
for (let i = 0; i < 1800; i += 1) {
const city = pick(CITIES)
const membership = pick(MEMBERSHIPS)
const baseline = CITY_BASE[city] * TIER_MULT[membership]
out.push({
age: pick(AGES),
time: pick(TIMES),
city, gender: pick(GENDERS), membership,
spend: Math.round(baseline * (0.55 + rnd() * 1.4)),
spendChange: Math.round((rnd() * 60 - 18) * 10) / 10,
rating: Math.round((2.6 + rnd() * 2.4) * 100) / 100,
discount: rnd() < 0.32 ? 'Yes' : 'No',
})
}
return out
}
const facts: Fact[] = seedFacts()
// ---- Fields offered to the designer's left rail --------------------
const fields: PivotField<Fact>[] = [
{ field: 'city', label: 'City', kind: 'dimension', group: 'Customer' },
{ field: 'membership', label: 'Membership', kind: 'dimension', group: 'Customer' },
{ field: 'age', label: 'Age band', kind: 'dimension', group: 'Customer' },
{ field: 'gender', label: 'Gender', kind: 'dimension', group: 'Customer' },
{ field: 'time', label: 'Time bucket', kind: 'dimension', group: 'Visit' },
{ field: 'discount', label: 'Discount?', kind: 'dimension', group: 'Visit' },
{ field: 'spend', label: 'Total spend', kind: 'measure', defaultAgg: 'sum',
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
{ field: 'rating', label: 'Avg rating', kind: 'measure', defaultAgg: 'avg',
format: { type: 'number', options: { maximumFractionDigits: 2 } } },
{ field: 'spendChange', label: 'Spend Δ%', kind: 'measure', defaultAgg: 'avg',
format: { type: 'number', options: { maximumFractionDigits: 1 } } },
]
// Saved layouts shown in the designer's toolbar Presets menu.
const presets: PivotPreset[] = [
{
name: 'Spend by city × hour',
layout: {
rows: ['city'], cols: ['time'],
values: [{ field: 'spend', agg: 'sum', label: 'Spend',
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } }],
filters: [],
},
},
{
name: 'Tier scorecard',
layout: {
rows: ['membership', 'city'], cols: [],
values: [
{ field: 'spend', agg: 'sum',
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
{ field: 'rating', agg: 'avg', format: { type: 'number', options: { maximumFractionDigits: 2 } } },
{ field: 'spend', agg: 'avg', label: 'Avg ticket',
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },
],
filters: [],
},
},
{
name: 'Demographics deep-dive',
layout: {
rows: ['age', 'gender'], cols: ['membership'],
values: [{ field: 'spend', agg: 'avg', label: 'Avg spend',
format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } }],
filters: [{ field: 'discount', allowed: ['Yes'] }],
},
},
]
// Bindable layout - the designer reads + writes this single shape.
let layout = $state<PivotLayout>(presets[0]!.layout)
function onExport(layout: PivotLayout, rows: unknown[]) {
console.log('Pivot export:', { layout, rowCount: rows.length })
alert(`Would export ${rows.length} pivot rows. See console for layout payload.`)
}
</script>
<section class="flex flex-col flex-1 min-h-0 gap-3">
<div class="shrink-0 rounded-lg border px-4 py-3" style="border-color: var(--sg-border); background: var(--sg-header-bg);">
<p class="text-sm font-semibold" style="color: var(--sg-fg);">
Loyalty analytics workspace - built on <SvPivotDesigner>
</p>
<p class="mt-0.5 text-xs" style="color: var(--sg-muted);">
1,800 simulated visits across 6 cities × 3 membership tiers × 8 time buckets. Drag any
field into the wells, change measures' aggregators (sum / avg / min / max / …), or pick a
preset to jump to a curated view. Filters narrow the source rows before the pivot.
</p>
</div>
<div class="flex-1 min-h-0">
<SvPivotDesigner
data={facts}
{fields}
bind:layout
{presets}
{onExport}
/>
</div>
</section>More Pivot Grid examples
- Pivot mode grid - Built on <SvPivotDesigner panelPosition="right">: a docked tool panel with a PIVOT MODE toggle, field checklist, and Columns / Rows / Values wells (drag-and-drop). OFF renders the flat participant grid (column groups, flags, ratings, inline filter row); ON pivots Language -> Country x Game with heat-mapped avg measures.
- Pivot - Table + Designer - Drag-and-drop Pivot Designer with Filters / Rows / Columns / Values zones, multi-level column headers, subtotal + grand-total rows, row-header sort menu.
- Pivot - Sales pipeline - Polished pivot view: KPI strip, region/sales-person rows, quarter columns, two measures, expand-all/collapse-all toolbar, heatmap tinting.
- Pivot - Conditional cells - Function-valued cell and header templates on top of createPivotModel: traffic-light revenue pills, target chips, units data-bars, measure icons in headers, region color dots in row labels.
- Pivot - Drill-through - Click any pivot value cell - leaf, subtotal, or grand total - and the right rail opens with the source facts behind the aggregate. Total + count + average always match the cell.