@svgrid/create 2.7.0 → 3.0.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/index.mjs +5 -1
- package/package.json +2 -2
- 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/_package.json +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/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
package/index.mjs
CHANGED
|
@@ -24,9 +24,13 @@ const TEMPLATES = {
|
|
|
24
24
|
bundled: join(__dirname, 'templates', 'minimal'),
|
|
25
25
|
},
|
|
26
26
|
sveltekit: {
|
|
27
|
-
label: 'SvelteKit - server load, URL-driven sort, form-action edits,
|
|
27
|
+
label: 'SvelteKit - server load, URL-driven sort, form-action edits, cookie auth + roles',
|
|
28
28
|
bundled: join(__dirname, 'templates', 'sveltekit'),
|
|
29
29
|
},
|
|
30
|
+
'pivot-dashboard': {
|
|
31
|
+
label: 'Pivot dashboard - pivot cube + linked chart + drill-through (needs @svgrid/enterprise)',
|
|
32
|
+
bundled: join(__dirname, 'templates', 'pivot-dashboard'),
|
|
33
|
+
},
|
|
30
34
|
'admin-dashboard': {
|
|
31
35
|
label: 'Admin dashboard - SvelteKit shell, multiple grids, deploy to Vercel',
|
|
32
36
|
bundled: join(__dirname, 'templates', 'admin-dashboard'),
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"type": "commercial",
|
|
5
5
|
"url": "https://svgrid.com/pricing"
|
|
6
6
|
},
|
|
7
|
-
"version": "
|
|
7
|
+
"version": "3.0.0",
|
|
8
8
|
"description": "Scaffold a Svelte app powered by SvGrid in one command: npm create @svgrid@latest",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"license": "MIT",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"node": ">=18"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@svgrid/grid": "^
|
|
39
|
+
"@svgrid/grid": "^3.0.0"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"sync-templates": "node sync-templates.mjs"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# SvGrid pivot dashboard
|
|
2
|
+
|
|
3
|
+
A pivot cube, a linked chart, and a drill-through rail over one fact table -
|
|
4
|
+
the three pieces a reporting screen usually needs, wired so they cannot
|
|
5
|
+
disagree with each other.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run dev # http://localhost:5173/
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## What to try
|
|
13
|
+
|
|
14
|
+
1. **Drag `Channel` into Rows** in the panel on the right. The cube, the chart
|
|
15
|
+
and any open drill all follow, because all three read the same layout.
|
|
16
|
+
2. **Click any aggregated cell.** The rail opens with the exact rows behind that
|
|
17
|
+
number. The KPI at the top is recomputed from those rows, so it always equals
|
|
18
|
+
the cell you clicked.
|
|
19
|
+
3. **Click a subtotal, then the grand total.** Same code path - a subtotal
|
|
20
|
+
simply contributes fewer filter terms, so the slice widens. The grand total
|
|
21
|
+
filters on nothing and returns all 1,800 facts.
|
|
22
|
+
4. **Switch the measure** to `Units` in the Values well. The chart axis, the
|
|
23
|
+
rail KPI and the formatting all switch with it.
|
|
24
|
+
5. **Click a bar** in the chart to drill that whole dimension value.
|
|
25
|
+
|
|
26
|
+
## How the drill works
|
|
27
|
+
|
|
28
|
+
A pivot cell is the intersection of a row path and a column path, and the two
|
|
29
|
+
arrive in different shapes:
|
|
30
|
+
|
|
31
|
+
- the **row** path is the chain of ancestors up to the clicked `PivotRow`,
|
|
32
|
+
matched positionally against `layout.rows`;
|
|
33
|
+
- the **column** path is encoded in the column id - `pv__<dim>__<dim>__m<i>`,
|
|
34
|
+
where the trailing `m<i>` indexes into `layout.values`.
|
|
35
|
+
|
|
36
|
+
`src/lib/drill.ts` decodes both into one `{ field: value }` filter and returns
|
|
37
|
+
the facts that match every entry. Because the total is recomputed from those
|
|
38
|
+
same facts rather than read off the grid, the rail and the cube cannot drift
|
|
39
|
+
apart.
|
|
40
|
+
|
|
41
|
+
That module is deliberately free of Svelte and of the grid packages, so you can
|
|
42
|
+
unit-test your own reporting rules against it.
|
|
43
|
+
|
|
44
|
+
## Where things are
|
|
45
|
+
|
|
46
|
+
| File | Does |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `src/lib/facts.ts` | The fact table. Seeded and deterministic. Swap `loadFacts` for your query. |
|
|
49
|
+
| `src/lib/drill.ts` | Pure drill-through: cell -> filter -> facts. No Svelte, no grid imports. |
|
|
50
|
+
| `src/routes/+page.server.ts` | Builds the facts on the server and sends them with the page. |
|
|
51
|
+
| `src/routes/+page.svelte` | The dashboard: designer, chart and rail over one `facts` array. |
|
|
52
|
+
| `src/routes/DrillRail.svelte` | The rail: KPIs plus a grid of the underlying rows. |
|
|
53
|
+
| `src/routes/TrendChart.svelte` | The bar chart. Inline SVG-free DOM, styled with `--sg-*`. |
|
|
54
|
+
|
|
55
|
+
## Licensing
|
|
56
|
+
|
|
57
|
+
`SvPivotDesigner` is part of `@svgrid/enterprise`, which is commercial. The app
|
|
58
|
+
runs unlicensed - it just nudges - so you can evaluate it before buying. Replace
|
|
59
|
+
the key in `src/routes/+page.svelte`:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
setLicenseKey('SVENTERPRISE-DEV-DEMO')
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Pricing: https://svgrid.com/pricing
|
|
66
|
+
|
|
67
|
+
## Going to production
|
|
68
|
+
|
|
69
|
+
The facts are generated in-process. Point `loadFacts` at your warehouse and keep
|
|
70
|
+
the aggregation on the server if the fact table is large - the browser should
|
|
71
|
+
receive facts, not a database connection. Everything downstream takes `Fact[]`,
|
|
72
|
+
so nothing else has to change.
|
|
73
|
+
|
|
74
|
+
Full guide: https://svgrid.com/docs/enterprise/pivot/
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
node_modules
|
|
2
|
+
|
|
3
|
+
# Output
|
|
4
|
+
.output
|
|
5
|
+
.vercel
|
|
6
|
+
.netlify
|
|
7
|
+
.wrangler
|
|
8
|
+
/.svelte-kit
|
|
9
|
+
/build
|
|
10
|
+
|
|
11
|
+
# OS
|
|
12
|
+
.DS_Store
|
|
13
|
+
Thumbs.db
|
|
14
|
+
|
|
15
|
+
# Env
|
|
16
|
+
.env
|
|
17
|
+
.env.*
|
|
18
|
+
!.env.example
|
|
19
|
+
!.env.test
|
|
20
|
+
|
|
21
|
+
# Vite
|
|
22
|
+
vite.config.js.timestamp-*
|
|
23
|
+
vite.config.ts.timestamp-*
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "svgrid-pivot-dashboard",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite dev",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"prepare": "svelte-kit sync || echo ''",
|
|
11
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
12
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@sveltejs/adapter-auto": "^7.0.1",
|
|
16
|
+
"@sveltejs/kit": "^2.63.0",
|
|
17
|
+
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
18
|
+
"svelte": "^5.56.1",
|
|
19
|
+
"svelte-check": "^4.6.0",
|
|
20
|
+
"typescript": "^6.0.3",
|
|
21
|
+
"vite": "^8.0.16"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@svgrid/grid": "^3.0.0",
|
|
25
|
+
"@svgrid/enterprise": "^3.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/* SvGrid theme. One of the 20 presets @svgrid/grid ships.
|
|
2
|
+
*
|
|
3
|
+
* Importing it as a stylesheet means the FIRST paint - and the server-rendered
|
|
4
|
+
* HTML - is already themed, before any JavaScript runs. The theme picker in the
|
|
5
|
+
* layout overrides these values at runtime by setting the same --sg-* custom
|
|
6
|
+
* properties on <html>.
|
|
7
|
+
*
|
|
8
|
+
* `npm create @svgrid@latest -- --theme <id>` rewrites the line between the
|
|
9
|
+
* markers, so pick a starting theme at scaffold time if you prefer. */
|
|
10
|
+
/* svgrid-theme:start */
|
|
11
|
+
@import '@svgrid/grid/themes/ember.css';
|
|
12
|
+
/* svgrid-theme:end */
|
|
13
|
+
|
|
14
|
+
* { box-sizing: border-box; }
|
|
15
|
+
body { margin: 0; }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="text-scale" content="scale" />
|
|
7
|
+
<script>
|
|
8
|
+
// Settle the theme before the first paint. Without this the server-rendered
|
|
9
|
+
// HTML shows one palette and the picker in +layout.svelte swaps it a frame
|
|
10
|
+
// later. With nothing saved yet we follow the OS.
|
|
11
|
+
try {
|
|
12
|
+
var saved = JSON.parse(localStorage.getItem('svgrid-theme') || 'null')
|
|
13
|
+
var fallback = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
14
|
+
document.documentElement.dataset.theme = (saved && saved.mode) || fallback
|
|
15
|
+
} catch (e) {}
|
|
16
|
+
</script>
|
|
17
|
+
%sveltekit.head%
|
|
18
|
+
</head>
|
|
19
|
+
<body data-sveltekit-preload-data="hover">
|
|
20
|
+
<div style="display: contents">%sveltekit.body%</div>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
@@ -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() })
|