@svgrid/create 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +11 -5
  2. package/index.mjs +486 -466
  3. package/package.json +2 -2
  4. package/templates/headless/README.md +65 -0
  5. package/templates/headless/_gitignore +4 -0
  6. package/templates/headless/_package.json +22 -0
  7. package/templates/headless/index.html +12 -0
  8. package/templates/headless/src/App.svelte +159 -0
  9. package/templates/headless/src/app.css +153 -0
  10. package/templates/headless/src/main.ts +7 -0
  11. package/templates/headless/src/vite-env.d.ts +2 -0
  12. package/templates/headless/svelte.config.js +5 -0
  13. package/templates/headless/tsconfig.json +14 -0
  14. package/templates/headless/vite.config.js +6 -0
  15. package/templates/minimal/src/app.css +1 -1
  16. package/templates/pivot-dashboard/README.md +74 -0
  17. package/templates/pivot-dashboard/_gitignore +23 -0
  18. package/templates/pivot-dashboard/_package.json +27 -0
  19. package/templates/pivot-dashboard/src/app.css +15 -0
  20. package/templates/pivot-dashboard/src/app.d.ts +11 -0
  21. package/templates/pivot-dashboard/src/app.html +22 -0
  22. package/templates/pivot-dashboard/src/lib/drill.ts +141 -0
  23. package/templates/pivot-dashboard/src/lib/facts.ts +81 -0
  24. package/templates/pivot-dashboard/src/lib/theme.svelte.ts +87 -0
  25. package/templates/pivot-dashboard/src/routes/+layout.svelte +79 -0
  26. package/templates/pivot-dashboard/src/routes/+page.server.ts +11 -0
  27. package/templates/pivot-dashboard/src/routes/+page.svelte +138 -0
  28. package/templates/pivot-dashboard/src/routes/DrillRail.svelte +82 -0
  29. package/templates/pivot-dashboard/src/routes/TrendChart.svelte +62 -0
  30. package/templates/pivot-dashboard/tsconfig.json +20 -0
  31. package/templates/pivot-dashboard/vite.config.ts +20 -0
  32. package/templates/sveltekit/README.md +33 -3
  33. package/templates/sveltekit/src/app.css +1 -1
  34. package/templates/sveltekit/src/app.d.ts +10 -2
  35. package/templates/sveltekit/src/hooks.server.ts +49 -0
  36. package/templates/sveltekit/src/lib/server/auth.ts +177 -0
  37. package/templates/sveltekit/src/lib/theme.svelte.ts +1 -1
  38. package/templates/sveltekit/src/routes/+layout.server.ts +10 -0
  39. package/templates/sveltekit/src/routes/+layout.svelte +16 -1
  40. package/templates/sveltekit/src/routes/login/+page.server.ts +51 -0
  41. package/templates/sveltekit/src/routes/login/+page.svelte +55 -0
  42. package/templates/sveltekit/src/routes/logout/+server.ts +24 -0
  43. package/templates/sveltekit/src/routes/people/+page.server.ts +12 -3
  44. package/templates/sveltekit/src/routes/people/+page.svelte +10 -5
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Drill-through: turn a clicked pivot cell back into the facts behind it.
3
+ *
4
+ * A pivot cell is the intersection of a row path and a column path. To find its
5
+ * source rows you need both, and the pivot gives them in two different shapes:
6
+ *
7
+ * - the **row** path is the chain of ancestors up to the clicked `PivotRow`,
8
+ * matched positionally against `layout.rows`;
9
+ * - the **column** path is encoded in the column id, `pv__<dim>__<dim>__m<i>`,
10
+ * where the trailing `m<i>` indexes into `layout.values`.
11
+ *
12
+ * Both decode to a plain `{ field: value }` filter, and the facts that match
13
+ * every entry are the ones that produced the cell. Because the total is
14
+ * recomputed from those same facts, the rail can never disagree with the grid.
15
+ *
16
+ * Kept free of Svelte and of the grid packages so it can be unit-tested on its
17
+ * own - see drill.test.ts.
18
+ */
19
+ import type { Fact } from './facts'
20
+
21
+ /** The subset of `PivotRow` this needs. Structural, so it does not drag the
22
+ * enterprise types into a file that is otherwise pure. */
23
+ export type PivotRowLike = {
24
+ __pivotId?: string
25
+ __pivotParentId?: string | null
26
+ __pivotLabel?: unknown
27
+ __pivotKind?: string
28
+ }
29
+
30
+ /** The subset of `PivotLayout` this needs. */
31
+ export type PivotLayoutLike = {
32
+ rows: string[]
33
+ cols: string[]
34
+ values: { field: string }[]
35
+ }
36
+
37
+ export type Drill = {
38
+ /** Human label for the clicked row, for the rail heading. */
39
+ rowLabel: string
40
+ /** Field/value pairs the facts must match. */
41
+ filter: Record<string, string>
42
+ /** The facts behind the cell. */
43
+ facts: Fact[]
44
+ /** Which measure the clicked column showed. */
45
+ measure: string
46
+ /** That measure summed over `facts` - equal to the cell the user clicked. */
47
+ total: number
48
+ }
49
+
50
+ /**
51
+ * Walk from the clicked row up to the root, then match the chain positionally
52
+ * against `layout.rows`.
53
+ *
54
+ * The grand-total row filters on nothing: every fact contributed to it.
55
+ */
56
+ export function rowFilterFor(
57
+ row: PivotRowLike,
58
+ allRows: PivotRowLike[],
59
+ layout: PivotLayoutLike,
60
+ ): Record<string, string> {
61
+ if (row.__pivotKind === 'grandTotal') return {}
62
+ const chain: PivotRowLike[] = []
63
+ let current: PivotRowLike | undefined = row
64
+ while (current) {
65
+ chain.unshift(current)
66
+ // Annotated: without it TypeScript infers `parentId` from `current`, which
67
+ // this line then reassigns, and the inference becomes circular.
68
+ const parentId: string | null | undefined = current.__pivotParentId
69
+ current = parentId ? allRows.find((r) => r.__pivotId === parentId) : undefined
70
+ }
71
+ const filter: Record<string, string> = {}
72
+ for (let i = 0; i < chain.length && i < layout.rows.length; i += 1) {
73
+ filter[layout.rows[i]!] = String(chain[i]!.__pivotLabel)
74
+ }
75
+ return filter
76
+ }
77
+
78
+ /**
79
+ * Decode a pivot column id into its column-axis filter and its measure.
80
+ *
81
+ * A subtotal column carries fewer dimension segments than `layout.cols` has,
82
+ * which is exactly right: fewer constraints means a wider slice.
83
+ */
84
+ export function colFilterFor(
85
+ colId: string,
86
+ layout: PivotLayoutLike,
87
+ ): { filter: Record<string, string>; measure: string } {
88
+ const parts = colId.startsWith('pv__') ? colId.slice(4).split('__') : []
89
+ const last = parts[parts.length - 1]
90
+ const measureIndex = last?.startsWith('m') ? Number(last.slice(1)) : 0
91
+ const dimensions = parts.slice(0, Math.max(0, parts.length - 1))
92
+
93
+ const filter: Record<string, string> = {}
94
+ for (let i = 0; i < dimensions.length && i < layout.cols.length; i += 1) {
95
+ filter[layout.cols[i]!] = dimensions[i]!
96
+ }
97
+ return { filter, measure: layout.values[measureIndex]?.field ?? layout.values[0]?.field ?? 'revenue' }
98
+ }
99
+
100
+ /**
101
+ * The facts behind one clicked cell, plus the measure total over them.
102
+ *
103
+ * Works for leaf cells, subtotals and the grand total without special-casing:
104
+ * each simply contributes fewer filter entries, so the slice widens.
105
+ */
106
+ export function drillThrough(
107
+ facts: Fact[],
108
+ row: PivotRowLike,
109
+ colId: string,
110
+ allRows: PivotRowLike[],
111
+ layout: PivotLayoutLike,
112
+ ): Drill {
113
+ const filter = { ...rowFilterFor(row, allRows, layout), ...colFilterFor(colId, layout).filter }
114
+ const { measure } = colFilterFor(colId, layout)
115
+
116
+ const matched = facts.filter((fact) => {
117
+ for (const [field, value] of Object.entries(filter)) {
118
+ if (String((fact as unknown as Record<string, unknown>)[field]) !== value) return false
119
+ }
120
+ return true
121
+ })
122
+
123
+ const total = matched.reduce(
124
+ (sum, fact) => sum + Number((fact as unknown as Record<string, unknown>)[measure] ?? 0),
125
+ 0,
126
+ )
127
+
128
+ return {
129
+ rowLabel: row.__pivotKind === 'grandTotal' ? 'Grand total' : String(row.__pivotLabel ?? ''),
130
+ filter,
131
+ facts: matched,
132
+ measure,
133
+ total,
134
+ }
135
+ }
136
+
137
+ /** "EMEA / Germany / Q1" - the filter read back as a breadcrumb. */
138
+ export function drillBreadcrumb(drill: Drill): string {
139
+ const parts = Object.values(drill.filter)
140
+ return parts.length ? parts.join(' / ') : 'All data'
141
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The fact table the whole dashboard is built on.
3
+ *
4
+ * A pivot summarises facts; a drill-through walks back to the exact facts
5
+ * behind one summarised cell. Both read this one array, which is why the number
6
+ * in the grid and the number in the drill rail can never disagree.
7
+ *
8
+ * Stands in for your warehouse. Swap `loadFacts` for a query and nothing
9
+ * downstream changes - the pivot, the chart and the drill all take `Fact[]`.
10
+ */
11
+
12
+ export type Region = 'AMER' | 'EMEA' | 'APAC'
13
+ export type Channel = 'Online' | 'Retail' | 'Wholesale'
14
+ export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4'
15
+
16
+ export type Fact = {
17
+ id: number
18
+ year: number
19
+ quarter: Quarter
20
+ region: Region
21
+ country: string
22
+ city: string
23
+ channel: Channel
24
+ customer: string
25
+ revenue: number
26
+ units: number
27
+ }
28
+
29
+ /** Which country and city belong to which region - keeps the seed coherent, so
30
+ * drilling into EMEA never turns up a US city. */
31
+ const TOPOLOGY: Record<Region, Record<string, string[]>> = {
32
+ AMER: { USA: ['New York', 'Austin', 'Seattle'], Canada: ['Toronto', 'Vancouver'] },
33
+ EMEA: { Germany: ['Berlin', 'Munich'], UK: ['London', 'Manchester'], France: ['Paris'] },
34
+ APAC: { Japan: ['Tokyo', 'Osaka'], India: ['Mumbai', 'Bangalore'] },
35
+ }
36
+
37
+ const CHANNELS: Channel[] = ['Online', 'Retail', 'Wholesale']
38
+ const CUSTOMERS = [
39
+ 'Acme Corp', 'Globex', 'Initech', 'Umbrella', 'Vandelay', 'Pied Piper',
40
+ 'Hooli', 'Stark Industries', 'Tyrell', 'Wayne Ent.', 'Wonka', 'Cyberdyne',
41
+ ]
42
+
43
+ /**
44
+ * Seeded PRNG, so every reload and every server instance produces the same
45
+ * dataset. With `Math.random` the SSR pass and the hydrated client would
46
+ * generate different numbers and Svelte would report a hydration mismatch.
47
+ */
48
+ function makeRandom(seed: number): () => number {
49
+ let state = seed >>> 0
50
+ return () => {
51
+ state = (state * 1664525 + 1013904223) >>> 0
52
+ return state / 0xffffffff
53
+ }
54
+ }
55
+
56
+ /** Build the fact table. Deterministic for a given `count` and `seed`. */
57
+ export function loadFacts(count = 1800, seed = 0xda7a101): Fact[] {
58
+ const rnd = makeRandom(seed)
59
+ const pick = <T>(items: readonly T[]): T => items[Math.floor(rnd() * items.length)]!
60
+ const regions = Object.keys(TOPOLOGY) as Region[]
61
+
62
+ const facts: Fact[] = []
63
+ for (let id = 1; id <= count; id += 1) {
64
+ const region = pick(regions)
65
+ const country = pick(Object.keys(TOPOLOGY[region]))
66
+ const city = pick(TOPOLOGY[region][country]!)
67
+ facts.push({
68
+ id,
69
+ year: pick([2025, 2026] as const),
70
+ quarter: pick(['Q1', 'Q2', 'Q3', 'Q4'] as const),
71
+ region,
72
+ country,
73
+ city,
74
+ channel: pick(CHANNELS),
75
+ customer: pick(CUSTOMERS),
76
+ revenue: Math.round(2_000 + rnd() * 48_000),
77
+ units: Math.round(5 + rnd() * 300),
78
+ })
79
+ }
80
+ return facts
81
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Runtime theme switching.
3
+ *
4
+ * `@svgrid/grid/themes` ships 20 presets and a `resolveThemeTokens(preset, mode)`
5
+ * helper that returns the `--sg-*` custom properties for a given preset and
6
+ * light/dark mode. Writing those onto `<html>` re-themes the grid live - there
7
+ * is nothing to rebuild and no stylesheet to swap.
8
+ *
9
+ * `app.css` imports one preset as a stylesheet so the very first paint (and the
10
+ * server-rendered HTML) already has a theme before any JS runs. The values set
11
+ * here override it once the user picks something.
12
+ */
13
+ import {
14
+ getThemePreset,
15
+ resolveThemeTokens,
16
+ themePresets,
17
+ type ThemeMode,
18
+ } from '@svgrid/grid/themes'
19
+
20
+ /** Every preset, for the picker. */
21
+ export const presets = themePresets.map((p) => ({ id: p.id, name: p.name }))
22
+
23
+ const STORAGE_KEY = 'svgrid-theme'
24
+
25
+ // `npm create @svgrid@latest -- --theme <id> [--dark|--light]` patches the two
26
+ // values between these markers so the scaffolded app starts on the theme you
27
+ // asked for. INITIAL_MODE is only the fallback: a saved choice wins over it, and
28
+ // so does the OS preference when nobody has pinned a mode. The inline script in
29
+ // `app.html` settles the same question before the first paint.
30
+ /* svgrid-initial-theme:start */
31
+ export const INITIAL_THEME = 'ember'
32
+ export const INITIAL_MODE: ThemeMode = 'light'
33
+ /* svgrid-initial-theme:end */
34
+
35
+ type Saved = { id: string; mode: ThemeMode }
36
+
37
+ function restore(): Saved {
38
+ const fallback: Saved = { id: INITIAL_THEME, mode: INITIAL_MODE }
39
+ if (typeof document === 'undefined') return fallback
40
+ // Trust whatever app.html's inline script settled on, so the picker agrees
41
+ // with what is already on screen.
42
+ const painted = document.documentElement.dataset.theme
43
+ if (painted === 'dark' || painted === 'light') fallback.mode = painted
44
+ try {
45
+ const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null') as Saved | null
46
+ if (!saved || typeof saved.id !== 'string') return fallback
47
+ if (!getThemePreset(saved.id)) return fallback
48
+ return { id: saved.id, mode: saved.mode === 'dark' ? 'dark' : 'light' }
49
+ } catch {
50
+ return fallback
51
+ }
52
+ }
53
+
54
+ class ThemeState {
55
+ #initial = restore()
56
+ id = $state(this.#initial.id)
57
+ mode = $state<ThemeMode>(this.#initial.mode)
58
+
59
+ /** Push the current selection onto <html> as CSS custom properties. */
60
+ apply() {
61
+ if (typeof document === 'undefined') return
62
+ const tokens = resolveThemeTokens(getThemePreset(this.id) ?? getThemePreset(INITIAL_THEME)!, this.mode)
63
+ const root = document.documentElement
64
+ for (const [key, value] of Object.entries(tokens)) root.style.setProperty(key, value)
65
+ // Lets the browser theme form controls and scrollbars to match, and keeps
66
+ // any `[data-theme='dark']` rules in your own CSS in step.
67
+ root.style.colorScheme = this.mode
68
+ root.dataset.theme = this.mode
69
+ try {
70
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: this.id, mode: this.mode }))
71
+ } catch {
72
+ // Private mode, quota, a blocked origin - not worth breaking the page over.
73
+ }
74
+ }
75
+
76
+ set(id: string, mode: ThemeMode = this.mode) {
77
+ this.id = id
78
+ this.mode = mode
79
+ this.apply()
80
+ }
81
+
82
+ toggleMode() {
83
+ this.set(this.id, this.mode === 'dark' ? 'light' : 'dark')
84
+ }
85
+ }
86
+
87
+ export const theme = new ThemeState()
@@ -0,0 +1,79 @@
1
+ <script lang="ts">
2
+ import '../app.css'
3
+ import { theme, presets } from '$lib/theme.svelte'
4
+
5
+ let { children } = $props()
6
+
7
+ // Apply on mount so the picker's starting value wins over the stylesheet, and
8
+ // on every later change. Runs client-side only - the server-rendered HTML is
9
+ // already themed by the stylesheet import in app.css.
10
+ $effect(() => {
11
+ theme.apply()
12
+ })
13
+ </script>
14
+
15
+ <header class="bar">
16
+ <strong>Pivot dashboard</strong>
17
+
18
+ <label>
19
+ Theme
20
+ <select
21
+ value={theme.id}
22
+ onchange={(e) => theme.set(e.currentTarget.value)}
23
+ aria-label="Grid theme"
24
+ >
25
+ {#each presets as p (p.id)}
26
+ <option value={p.id}>{p.name}</option>
27
+ {/each}
28
+ </select>
29
+ </label>
30
+
31
+ <button type="button" onclick={() => theme.toggleMode()} aria-pressed={theme.mode === 'dark'}>
32
+ {theme.mode === 'dark' ? 'Dark' : 'Light'}
33
+ </button>
34
+
35
+ </header>
36
+
37
+ <main>
38
+ {@render children?.()}
39
+ </main>
40
+
41
+ <style>
42
+ .bar {
43
+ display: flex;
44
+ align-items: center;
45
+ gap: 1rem;
46
+ flex-wrap: wrap;
47
+ padding: 0.75rem 1.25rem;
48
+ border-bottom: 1px solid var(--sg-border);
49
+ background: var(--sg-header-bg);
50
+ color: var(--sg-header-fg);
51
+ font-family: system-ui, sans-serif;
52
+ }
53
+ .bar label {
54
+ display: flex;
55
+ align-items: center;
56
+ gap: 0.4rem;
57
+ font-size: 0.875rem;
58
+ }
59
+ .bar select,
60
+ .bar button {
61
+ font: inherit;
62
+ padding: 0.3rem 0.5rem;
63
+ border-radius: 6px;
64
+ border: 1px solid var(--sg-border);
65
+ background: var(--sg-bg);
66
+ color: var(--sg-fg);
67
+ }
68
+ .bar button {
69
+ cursor: pointer;
70
+ min-width: 4.5rem;
71
+ }
72
+ main {
73
+ padding: 1.25rem;
74
+ font-family: system-ui, sans-serif;
75
+ color: var(--sg-fg);
76
+ background: var(--sg-bg);
77
+ min-height: 100vh;
78
+ }
79
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { PageServerLoad } from './$types'
2
+ import { loadFacts } from '$lib/facts'
3
+
4
+ /**
5
+ * The fact table is built on the server and sent with the page.
6
+ *
7
+ * A pivot over a warehouse query belongs on the server: the browser should get
8
+ * facts, not a database connection. Swap `loadFacts` for your query and the
9
+ * rest of the dashboard is unchanged.
10
+ */
11
+ export const load: PageServerLoad = () => ({ facts: loadFacts() })
@@ -0,0 +1,138 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Pivot dashboard: one cube, one chart, one drill rail, all over the same
4
+ * facts.
5
+ *
6
+ * The point of the layout is that the three panes cannot disagree. The pivot
7
+ * summarises `facts`; the chart re-aggregates the same array along the first
8
+ * row dimension; the drill rail filters it back down to the rows behind one
9
+ * clicked cell. Change the layout in the designer and all three follow.
10
+ */
11
+ import { SvPivotDesigner, setLicenseKey, type PivotField, type PivotLayout, type PivotRow } from '@svgrid/enterprise'
12
+ import { drillThrough, type Drill } from '$lib/drill'
13
+ import type { Fact } from '$lib/facts'
14
+ import DrillRail from './DrillRail.svelte'
15
+ import TrendChart from './TrendChart.svelte'
16
+
17
+ // Swap for your own key. Unlicensed use still runs - it just nudges.
18
+ setLicenseKey('SVENTERPRISE-DEV-DEMO')
19
+
20
+ let { data } = $props()
21
+ const facts = $derived(data.facts as Fact[])
22
+
23
+ const fields: PivotField[] = [
24
+ { field: 'region', kind: 'dimension', label: 'Region' },
25
+ { field: 'country', kind: 'dimension', label: 'Country' },
26
+ { field: 'city', kind: 'dimension', label: 'City' },
27
+ { field: 'channel', kind: 'dimension', label: 'Channel' },
28
+ { field: 'customer', kind: 'dimension', label: 'Customer' },
29
+ { field: 'year', kind: 'dimension', label: 'Year' },
30
+ { field: 'quarter', kind: 'dimension', label: 'Quarter' },
31
+ { field: 'revenue', kind: 'measure', label: 'Revenue' },
32
+ { field: 'units', kind: 'measure', label: 'Units' },
33
+ ]
34
+
35
+ let layout = $state<PivotLayout>({
36
+ rows: ['region', 'country'],
37
+ cols: ['year'],
38
+ values: [{ field: 'revenue', agg: 'sum' }],
39
+ // Required by PivotLayout. Empty means every fact passes.
40
+ filters: [],
41
+ })
42
+
43
+ // The designer hands back the rows it rendered. Drill-through needs them to
44
+ // walk a clicked row up to its ancestors.
45
+ let pivotRows = $state<PivotRow[]>([])
46
+ let drill = $state<Drill | null>(null)
47
+
48
+ const money = (n: number) => n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
49
+ const plain = (n: number) => n.toLocaleString()
50
+ const measure = $derived(layout.values[0]?.field ?? 'revenue')
51
+ const format = $derived(measure === 'units' ? plain : money)
52
+
53
+ /** Chart series: the active measure totalled by the first row dimension. */
54
+ const series = $derived.by(() => {
55
+ const dimension = layout.rows[0]
56
+ if (!dimension) return []
57
+ const totals = new Map<string, number>()
58
+ for (const fact of facts) {
59
+ const key = String((fact as unknown as Record<string, unknown>)[dimension])
60
+ const value = Number((fact as unknown as Record<string, unknown>)[measure] ?? 0)
61
+ totals.set(key, (totals.get(key) ?? 0) + value)
62
+ }
63
+ return [...totals].map(([label, value]) => ({ label, value })).sort((a, b) => b.value - a.value)
64
+ })
65
+
66
+ /** The chart highlights whichever top-level value the drill is inside. */
67
+ const activeBar = $derived(drill && layout.rows[0] ? (drill.filter[layout.rows[0]] ?? null) : null)
68
+
69
+ function onCellClick(event: { row: PivotRow; columnId: string }) {
70
+ // The row-header column is a label, not an aggregation - nothing to drill.
71
+ if (event.columnId === '__pivotRowHeader') return
72
+ drill = drillThrough(facts, event.row, event.columnId, pivotRows, layout)
73
+ }
74
+
75
+ /** Clicking a bar drills the whole of that dimension value. */
76
+ function onBarSelect(label: string) {
77
+ const dimension = layout.rows[0]
78
+ if (!dimension) return
79
+ const matched = facts.filter((f) => String((f as unknown as Record<string, unknown>)[dimension]) === label)
80
+ drill = {
81
+ rowLabel: label,
82
+ filter: { [dimension]: label },
83
+ facts: matched,
84
+ measure,
85
+ total: matched.reduce((sum, f) => sum + Number((f as unknown as Record<string, unknown>)[measure] ?? 0), 0),
86
+ }
87
+ }
88
+ </script>
89
+
90
+ <svelte:head><title>Pivot dashboard</title></svelte:head>
91
+
92
+ <h1>Sales cube</h1>
93
+ <p class="lede">
94
+ {plain(facts.length)} facts, summarised on the server and pivoted in the browser.
95
+ Click any aggregated cell to see the rows behind it.
96
+ </p>
97
+
98
+ <div class="layout" class:has-drill={drill !== null}>
99
+ <section class="cube" aria-label="Pivot">
100
+ <SvPivotDesigner
101
+ data={facts}
102
+ {fields}
103
+ bind:layout
104
+ expandable
105
+ panelPosition="right"
106
+ onPivot={(rows: PivotRow[]) => (pivotRows = rows)}
107
+ {onCellClick}
108
+ />
109
+ </section>
110
+
111
+ <section class="chart" aria-label="Totals by {layout.rows[0] ?? 'dimension'}">
112
+ <h2>Total {measure} by {layout.rows[0] ?? 'dimension'}</h2>
113
+ <TrendChart {series} {format} active={activeBar} onSelect={onBarSelect} />
114
+ </section>
115
+
116
+ {#if drill}
117
+ <DrillRail {drill} {format} onClose={() => (drill = null)} />
118
+ {/if}
119
+ </div>
120
+
121
+ <style>
122
+ h1 { margin: 0 0 0.25rem; font-size: 1.4rem; }
123
+ .lede { margin: 0 0 1.25rem; color: var(--sg-muted, #64748b); }
124
+ .layout { display: grid; gap: 1rem; grid-template-columns: 1fr; }
125
+ .cube { min-width: 0; height: 460px; }
126
+ .chart {
127
+ min-width: 0; padding: 1rem;
128
+ border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
129
+ }
130
+ .chart h2 { margin: 0 0 0.75rem; font-size: 0.95rem; text-transform: capitalize; }
131
+ /* Side by side once there is room. Below this the panes stack, which keeps
132
+ the pivot usable on a phone instead of squeezing three columns in. */
133
+ @media (min-width: 60rem) {
134
+ .layout { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
135
+ .cube { grid-column: 1 / -1; }
136
+ .layout.has-drill .cube { grid-column: 1 / -1; }
137
+ }
138
+ </style>
@@ -0,0 +1,82 @@
1
+ <script lang="ts">
2
+ /**
3
+ * The drill-through rail: the facts behind one clicked pivot cell.
4
+ *
5
+ * The KPI at the top is recomputed from the same rows the grid below lists,
6
+ * so it always matches the cell that was clicked. If it ever did not, the
7
+ * drill filter and the pivot aggregation would have drifted apart.
8
+ */
9
+ import { SvGrid, type GridColumns } from '@svgrid/grid'
10
+ import type { Drill } from '$lib/drill'
11
+ import { drillBreadcrumb } from '$lib/drill'
12
+ import type { Fact } from '$lib/facts'
13
+
14
+ type Props = {
15
+ drill: Drill
16
+ format: (value: number) => string
17
+ onClose: () => void
18
+ }
19
+
20
+ let { drill, format, onClose }: Props = $props()
21
+
22
+ const columns: GridColumns<Fact> = [
23
+ { field: 'customer', header: 'Customer' },
24
+ { field: 'city', header: 'City' },
25
+ { field: 'channel', header: 'Channel' },
26
+ { field: 'quarter', header: 'Qtr' },
27
+ { field: 'revenue', header: 'Revenue', align: 'right' },
28
+ { field: 'units', header: 'Units', align: 'right' },
29
+ ]
30
+
31
+ const average = $derived(drill.facts.length ? drill.total / drill.facts.length : 0)
32
+ </script>
33
+
34
+ <aside class="rail" aria-label="Drill-through">
35
+ <header>
36
+ <div>
37
+ <p class="eyebrow">Drill-through</p>
38
+ <h2>{drillBreadcrumb(drill)}</h2>
39
+ </div>
40
+ <button type="button" onclick={onClose} aria-label="Close drill-through">&times;</button>
41
+ </header>
42
+
43
+ <dl class="kpis">
44
+ <div><dt>{drill.measure}</dt><dd>{format(drill.total)}</dd></div>
45
+ <div><dt>Facts</dt><dd>{drill.facts.length.toLocaleString()}</dd></div>
46
+ <div><dt>Average</dt><dd>{format(Math.round(average))}</dd></div>
47
+ </dl>
48
+
49
+ <div class="grid">
50
+ <SvGrid data={drill.facts} {columns} sortable containerHeight={340} />
51
+ </div>
52
+ </aside>
53
+
54
+ <style>
55
+ .rail {
56
+ display: flex; flex-direction: column; gap: 1rem; min-width: 0;
57
+ border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
58
+ background: var(--sg-bg, #fff); padding: 1rem;
59
+ }
60
+ header { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }
61
+ .eyebrow {
62
+ margin: 0 0 0.15rem; font-size: 0.6875rem; text-transform: uppercase;
63
+ letter-spacing: 0.06em; color: var(--sg-muted, #64748b);
64
+ }
65
+ h2 { margin: 0; font-size: 1rem; }
66
+ header button {
67
+ font: inherit; font-size: 1.25rem; line-height: 1; padding: 0.1rem 0.4rem; cursor: pointer;
68
+ background: none; border: 1px solid var(--sg-border, #e2e8f0);
69
+ border-radius: var(--sg-radius, 8px); color: var(--sg-fg, #0f172a);
70
+ }
71
+ .kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; margin: 0; }
72
+ .kpis div {
73
+ border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
74
+ padding: 0.5rem 0.6rem;
75
+ }
76
+ dt {
77
+ font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em;
78
+ color: var(--sg-muted, #64748b);
79
+ }
80
+ dd { margin: 0.15rem 0 0; font-size: 1rem; font-weight: 650; font-variant-numeric: tabular-nums; }
81
+ .grid { min-width: 0; }
82
+ </style>
@@ -0,0 +1,62 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A bar chart over whatever slice the dashboard is currently showing.
4
+ *
5
+ * Inline SVG on purpose: a starter should not pull a charting library in for
6
+ * one chart, and this way the bars are plain DOM you can style with the same
7
+ * `--sg-*` tokens as the grid.
8
+ */
9
+ type Props = {
10
+ /** Bars to draw, already aggregated. */
11
+ series: { label: string; value: number }[]
12
+ /** Formats the value in the tooltip and the axis. */
13
+ format: (value: number) => string
14
+ /** Highlighted bar, e.g. the one being drilled. */
15
+ active?: string | null
16
+ onSelect?: (label: string) => void
17
+ }
18
+
19
+ let { series, format, active = null, onSelect }: Props = $props()
20
+
21
+ const max = $derived(Math.max(1, ...series.map((s) => s.value)))
22
+ </script>
23
+
24
+ {#if series.length === 0}
25
+ <p class="empty">No data in this slice.</p>
26
+ {:else}
27
+ <ul class="bars">
28
+ {#each series as bar (bar.label)}
29
+ <li class="row" class:is-active={active === bar.label}>
30
+ <button
31
+ type="button"
32
+ onclick={() => onSelect?.(bar.label)}
33
+ title="{bar.label}: {format(bar.value)}"
34
+ >
35
+ <span class="label">{bar.label}</span>
36
+ <span class="track">
37
+ <!-- Width is the only inline style here: it is data, not design. -->
38
+ <span class="fill" style="width: {(bar.value / max) * 100}%"></span>
39
+ </span>
40
+ <span class="value">{format(bar.value)}</span>
41
+ </button>
42
+ </li>
43
+ {/each}
44
+ </ul>
45
+ {/if}
46
+
47
+ <style>
48
+ .bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
49
+ button {
50
+ display: grid; grid-template-columns: 8rem 1fr 6rem; align-items: center; gap: 0.75rem;
51
+ width: 100%; padding: 0.3rem 0.4rem; font: inherit; text-align: left;
52
+ background: none; border: 0; border-radius: var(--sg-radius, 8px); cursor: pointer;
53
+ color: var(--sg-fg, #0f172a);
54
+ }
55
+ button:hover { background: var(--sg-row-hover-bg, rgba(0, 0, 0, 0.04)); }
56
+ .row.is-active button { background: color-mix(in srgb, var(--sg-accent, #2563eb) 14%, transparent); }
57
+ .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.875rem; }
58
+ .track { background: var(--sg-border, #e2e8f0); border-radius: 999px; height: 0.7rem; overflow: hidden; }
59
+ .fill { display: block; height: 100%; background: var(--sg-accent, #2563eb); border-radius: 999px; }
60
+ .value { font-variant-numeric: tabular-nums; font-size: 0.8125rem; text-align: right; color: var(--sg-muted, #64748b); }
61
+ .empty { color: var(--sg-muted, #64748b); font-size: 0.875rem; margin: 0; }
62
+ </style>