@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.
- package/README.md +11 -5
- package/index.mjs +486 -466
- package/package.json +2 -2
- package/templates/headless/README.md +65 -0
- package/templates/headless/_gitignore +4 -0
- package/templates/headless/_package.json +22 -0
- package/templates/headless/index.html +12 -0
- package/templates/headless/src/App.svelte +159 -0
- package/templates/headless/src/app.css +153 -0
- package/templates/headless/src/main.ts +7 -0
- package/templates/headless/src/vite-env.d.ts +2 -0
- package/templates/headless/svelte.config.js +5 -0
- package/templates/headless/tsconfig.json +14 -0
- package/templates/headless/vite.config.js +6 -0
- package/templates/minimal/src/app.css +1 -1
- package/templates/pivot-dashboard/README.md +74 -0
- package/templates/pivot-dashboard/_gitignore +23 -0
- package/templates/pivot-dashboard/_package.json +27 -0
- package/templates/pivot-dashboard/src/app.css +15 -0
- package/templates/pivot-dashboard/src/app.d.ts +11 -0
- package/templates/pivot-dashboard/src/app.html +22 -0
- package/templates/pivot-dashboard/src/lib/drill.ts +141 -0
- package/templates/pivot-dashboard/src/lib/facts.ts +81 -0
- package/templates/pivot-dashboard/src/lib/theme.svelte.ts +87 -0
- package/templates/pivot-dashboard/src/routes/+layout.svelte +79 -0
- package/templates/pivot-dashboard/src/routes/+page.server.ts +11 -0
- package/templates/pivot-dashboard/src/routes/+page.svelte +138 -0
- package/templates/pivot-dashboard/src/routes/DrillRail.svelte +82 -0
- package/templates/pivot-dashboard/src/routes/TrendChart.svelte +62 -0
- package/templates/pivot-dashboard/tsconfig.json +20 -0
- package/templates/pivot-dashboard/vite.config.ts +20 -0
- package/templates/sveltekit/README.md +33 -3
- package/templates/sveltekit/src/app.css +1 -1
- package/templates/sveltekit/src/app.d.ts +10 -2
- package/templates/sveltekit/src/hooks.server.ts +49 -0
- package/templates/sveltekit/src/lib/server/auth.ts +177 -0
- package/templates/sveltekit/src/lib/theme.svelte.ts +1 -1
- package/templates/sveltekit/src/routes/+layout.server.ts +10 -0
- package/templates/sveltekit/src/routes/+layout.svelte +16 -1
- package/templates/sveltekit/src/routes/login/+page.server.ts +51 -0
- package/templates/sveltekit/src/routes/login/+page.svelte +55 -0
- package/templates/sveltekit/src/routes/logout/+server.ts +24 -0
- package/templates/sveltekit/src/routes/people/+page.server.ts +12 -3
- 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">×</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>
|