@heyramzi/ui 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/charts/dual-axis.js +38 -0
- package/dist/slides/deck-controller.svelte.js +62 -15
- package/dist/slides/layout.js +3 -2
- package/package.json +9 -2
- package/scripts/svelte-dts.mjs +3 -0
- package/src/charts/area-chart.css +6 -0
- package/src/charts/area-chart.svelte +115 -17
- package/src/charts/area-chart.svelte.d.ts +11 -2
- package/src/charts/chart-tokens.css +4 -3
- package/src/charts/dual-axis.test.ts +40 -0
- package/src/charts/dual-axis.ts +43 -0
- package/src/editor/editor-toolbar.svelte +19 -11
- package/src/editor/editor.svelte +1 -1
- package/src/slides/Deck.svelte +12 -2
- package/src/slides/Overview.svelte +10 -4
- package/src/slides/README.md +1 -1
- package/src/slides/Slide.svelte +17 -11
- package/src/slides/deck-controller.svelte.ts +62 -16
- package/src/slides/layout.ts +3 -2
- package/src/slides/theme.css +34 -5
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const NICE = [
|
|
2
|
+
1,
|
|
3
|
+
2,
|
|
4
|
+
2.5,
|
|
5
|
+
5,
|
|
6
|
+
10
|
|
7
|
+
];
|
|
8
|
+
/** The smallest 1, 2, 2.5 or 5 times a power of ten that is at least `raw`. */
|
|
9
|
+
export function niceStep(raw) {
|
|
10
|
+
if (!(raw > 0) || !Number.isFinite(raw)) return 1;
|
|
11
|
+
const power = 10 ** Math.floor(Math.log10(raw));
|
|
12
|
+
// WHY the epsilon: 0.3 / 0.1 is 2.9999999999999996, which would skip 3 x 0.1 for the next step.
|
|
13
|
+
const multiple = NICE.find((m) => m * power >= raw * (1 - 1e-9)) ?? 10;
|
|
14
|
+
return multiple * power;
|
|
15
|
+
}
|
|
16
|
+
function ticksOf(step, count) {
|
|
17
|
+
// Rounded so 0.1 x 3 reads 0.3 on the axis, not 0.30000000000000004.
|
|
18
|
+
return Array.from({ length: count + 1 }, (_, i) => Number((i * step).toPrecision(12)));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Left and right domains from 0, with the same tick count so both axes sit on one grid.
|
|
22
|
+
* `target` is the number of intervals wanted; the left data may round it to one more or less.
|
|
23
|
+
*/
|
|
24
|
+
export function dualAxis(leftMax, rightMax, target = 4) {
|
|
25
|
+
const leftStep = niceStep(Math.max(0, leftMax) / target);
|
|
26
|
+
const count = Math.max(1, Math.ceil(Math.max(0, leftMax) / leftStep - 1e-9));
|
|
27
|
+
const rightStep = niceStep(Math.max(0, rightMax) / count);
|
|
28
|
+
return {
|
|
29
|
+
left: {
|
|
30
|
+
max: leftStep * count,
|
|
31
|
+
ticks: ticksOf(leftStep, count)
|
|
32
|
+
},
|
|
33
|
+
right: {
|
|
34
|
+
max: rightStep * count,
|
|
35
|
+
ticks: ticksOf(rightStep, count)
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -5,9 +5,19 @@ import { resolveLabels } from "./labels.js";
|
|
|
5
5
|
import { DEFAULT_CANVAS } from "./types.js";
|
|
6
6
|
/** Height the controls bar takes at the bottom of the overview, in px. */
|
|
7
7
|
const CONTROLS_ROOM = 56;
|
|
8
|
-
/** How long the overview morph flag stays up, a little past the longest stagger. */
|
|
9
|
-
const MORPH_MS = 900;
|
|
10
8
|
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
9
|
+
/**
|
|
10
|
+
* Where `cell` lands when the camera moves `target` onto `fit`. Every box shares one scale and
|
|
11
|
+
* shift, and a transform tween is linear in both, so the grid stays rigid while it zooms.
|
|
12
|
+
*/
|
|
13
|
+
function frame(cell, target, fit) {
|
|
14
|
+
const k = fit.scale / target.scale;
|
|
15
|
+
return {
|
|
16
|
+
x: fit.x + (cell.x - target.x) * k,
|
|
17
|
+
y: fit.y + (cell.y - target.y) * k,
|
|
18
|
+
scale: cell.scale * k
|
|
19
|
+
};
|
|
20
|
+
}
|
|
11
21
|
function isEditable(el) {
|
|
12
22
|
return el.isContentEditable || [
|
|
13
23
|
"INPUT",
|
|
@@ -22,7 +32,7 @@ export class DeckController {
|
|
|
22
32
|
/** The highlighted thumbnail in the overview, as an index among the visible slides. */
|
|
23
33
|
cursor = $state(0);
|
|
24
34
|
direction = $state(1);
|
|
25
|
-
|
|
35
|
+
zoom = $state(null);
|
|
26
36
|
size = $state({
|
|
27
37
|
w: 0,
|
|
28
38
|
h: 0
|
|
@@ -30,6 +40,7 @@ export class DeckController {
|
|
|
30
40
|
options;
|
|
31
41
|
#listeners = new Set();
|
|
32
42
|
#timer;
|
|
43
|
+
#frame = 0;
|
|
33
44
|
constructor(options) {
|
|
34
45
|
this.options = options;
|
|
35
46
|
}
|
|
@@ -61,8 +72,13 @@ export class DeckController {
|
|
|
61
72
|
}, this.canvas, this.visible.length));
|
|
62
73
|
/** Where slide `index` sits right now: centred to fit, or in its overview cell. */
|
|
63
74
|
boxOf(index) {
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
const zoom = this.zoom;
|
|
76
|
+
const cell = this.grid.boxes[this.visible.indexOf(index)];
|
|
77
|
+
if (zoom?.framed) {
|
|
78
|
+
const target = this.grid.boxes[this.visible.indexOf(zoom.index)];
|
|
79
|
+
return cell && target ? frame(cell, target, this.fit) : this.fit;
|
|
80
|
+
}
|
|
81
|
+
return this.overview || zoom ? cell : this.fit;
|
|
66
82
|
}
|
|
67
83
|
register(entry) {
|
|
68
84
|
untrack(() => {
|
|
@@ -78,7 +94,7 @@ export class DeckController {
|
|
|
78
94
|
return () => this.#listeners.delete(listener);
|
|
79
95
|
}
|
|
80
96
|
mount() {
|
|
81
|
-
return () =>
|
|
97
|
+
return () => this.#stopZoom();
|
|
82
98
|
}
|
|
83
99
|
/** Which deck hears a key is the page's call: Deck passes only keys from focus inside it. */
|
|
84
100
|
get listening() {
|
|
@@ -88,14 +104,15 @@ export class DeckController {
|
|
|
88
104
|
this.options.index = position.index;
|
|
89
105
|
this.options.step = position.step;
|
|
90
106
|
}
|
|
91
|
-
|
|
107
|
+
/** `animate: false` skips the slide transition, for a move the overview zoom already shows. */
|
|
108
|
+
commit(target, source = "local", animate = true) {
|
|
92
109
|
if (!target) return;
|
|
93
110
|
const from = this.position;
|
|
94
111
|
const to = settle(this.slides, target);
|
|
95
112
|
if (to.index === from.index && to.step === from.step) return;
|
|
96
113
|
this.direction = to.index >= from.index ? 1 : -1;
|
|
97
114
|
const apply = () => this.#write(to);
|
|
98
|
-
if (to.index !== from.index && this.options.transition) this.options.transition(apply);
|
|
115
|
+
if (animate && to.index !== from.index && this.options.transition) this.options.transition(apply);
|
|
99
116
|
else apply();
|
|
100
117
|
for (const listener of this.#listeners) listener(to, source);
|
|
101
118
|
}
|
|
@@ -111,23 +128,53 @@ export class DeckController {
|
|
|
111
128
|
step
|
|
112
129
|
}, source);
|
|
113
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Opening zooms the camera out from the current slide to the whole grid. Closing zooms into
|
|
133
|
+
* the current slide, so `select` moves there first.
|
|
134
|
+
*/
|
|
114
135
|
toggleOverview(force) {
|
|
115
136
|
const open = force ?? !this.overview;
|
|
116
137
|
if (open === this.overview) return;
|
|
117
|
-
|
|
138
|
+
const index = this.position.index;
|
|
139
|
+
if (open) this.cursor = Math.max(0, this.visible.indexOf(index));
|
|
118
140
|
this.view = open ? "overview" : "slide";
|
|
119
|
-
this.#
|
|
141
|
+
this.#stopZoom();
|
|
142
|
+
const ms = this.options.zoomMs?.() ?? 0;
|
|
143
|
+
if (ms <= 0 || !this.visible.includes(index)) return;
|
|
144
|
+
/*
|
|
145
|
+
* Each way paints its start state first and moves on a later frame. Moved in the same
|
|
146
|
+
* flush, a style read in between (the overview handing focus back) commits the boxes
|
|
147
|
+
* before the transition rule is on, and the zoom jumps to its end.
|
|
148
|
+
*/
|
|
149
|
+
this.zoom = {
|
|
150
|
+
index,
|
|
151
|
+
framed: open,
|
|
152
|
+
running: !open
|
|
153
|
+
};
|
|
154
|
+
this.#frame = requestAnimationFrame(() => {
|
|
155
|
+
this.#frame = requestAnimationFrame(() => {
|
|
156
|
+
this.zoom = {
|
|
157
|
+
index,
|
|
158
|
+
framed: !open,
|
|
159
|
+
running: true
|
|
160
|
+
};
|
|
161
|
+
this.#timer = setTimeout(() => this.#stopZoom(), ms);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
120
164
|
}
|
|
121
165
|
/** Leaves the overview on slide `index`. Picking the current slide keeps its step. */
|
|
122
166
|
select(index) {
|
|
123
|
-
this.toggleOverview(false);
|
|
124
167
|
const keep = index === this.position.index;
|
|
125
|
-
this.
|
|
168
|
+
this.commit({
|
|
169
|
+
index,
|
|
170
|
+
step: keep ? this.position.step : 0
|
|
171
|
+
}, "local", false);
|
|
172
|
+
this.toggleOverview(false);
|
|
126
173
|
}
|
|
127
|
-
#
|
|
128
|
-
this.morphing = true;
|
|
174
|
+
#stopZoom() {
|
|
129
175
|
clearTimeout(this.#timer);
|
|
130
|
-
this.#
|
|
176
|
+
cancelAnimationFrame(this.#frame);
|
|
177
|
+
this.zoom = null;
|
|
131
178
|
}
|
|
132
179
|
#moveCursor(to) {
|
|
133
180
|
this.cursor = clamp(to, 0, this.visible.length - 1);
|
package/dist/slides/layout.js
CHANGED
|
@@ -10,7 +10,8 @@ export function fitBox(view, canvas) {
|
|
|
10
10
|
function scaleFor(cols, count, view, canvas, pad, gap) {
|
|
11
11
|
const rows = Math.ceil(count / cols);
|
|
12
12
|
const byWidth = (view.w - 2 * pad - (cols - 1) * gap) / cols / canvas.w;
|
|
13
|
-
|
|
13
|
+
/* Every row, the last too, keeps a gap under it for its numbers. */
|
|
14
|
+
const byHeight = (view.h - 2 * pad - rows * gap) / rows / canvas.h;
|
|
14
15
|
return Math.min(byWidth, byHeight);
|
|
15
16
|
}
|
|
16
17
|
/** Lays `count` canvases in the roomiest grid that shows them all, centred. */
|
|
@@ -32,7 +33,7 @@ export function gridBoxes(view, canvas, count) {
|
|
|
32
33
|
const cellH = canvas.h * scale;
|
|
33
34
|
const rows = Math.ceil(count / cols);
|
|
34
35
|
const left = (view.w - (cols * cellW + (cols - 1) * gap)) / 2;
|
|
35
|
-
const top = (view.h -
|
|
36
|
+
const top = (view.h - rows * (cellH + gap)) / 2;
|
|
36
37
|
const boxes = Array.from({ length: count }, (_, i) => ({
|
|
37
38
|
x: left + i % cols * (cellW + gap),
|
|
38
39
|
y: top + Math.floor(i / cols) * (cellH + gap),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
|
|
5
5
|
"homepage": "https://ui.heyramzi.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -271,6 +271,10 @@
|
|
|
271
271
|
"default": "./src/editor/block-ops.ts"
|
|
272
272
|
},
|
|
273
273
|
"./editor/inline-editor": {
|
|
274
|
+
"types": [
|
|
275
|
+
"./src/editor/inline-editor.svelte.d.ts",
|
|
276
|
+
"./src/editor/inline-editor.svelte"
|
|
277
|
+
],
|
|
274
278
|
"svelte": "./src/editor/inline-editor.svelte",
|
|
275
279
|
"default": "./src/editor/inline-editor.svelte"
|
|
276
280
|
},
|
|
@@ -291,7 +295,10 @@
|
|
|
291
295
|
"default": "./src/canvas/index.ts"
|
|
292
296
|
},
|
|
293
297
|
"./canvas/*.svelte": {
|
|
294
|
-
"types":
|
|
298
|
+
"types": [
|
|
299
|
+
"./src/canvas/*.svelte.d.ts",
|
|
300
|
+
"./src/canvas/*.svelte"
|
|
301
|
+
],
|
|
295
302
|
"svelte": "./src/canvas/*.svelte",
|
|
296
303
|
"default": "./src/canvas/*.svelte"
|
|
297
304
|
},
|
package/scripts/svelte-dts.mjs
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// `.svelte` under node_modules, so without them every component types as `any`.
|
|
4
4
|
|
|
5
5
|
// prepack writes them, postpack deletes them (`--clean`): left in src, they'd shadow the components.
|
|
6
|
+
// An export whose target is a `.svelte` file lists its `types` as [`x.svelte.d.ts`, `x.svelte`]:
|
|
7
|
+
// from a `.svelte` target TypeScript only looks for `x.d.svelte.ts`, and svelte-check stops
|
|
8
|
+
// faking that file once a real `x.svelte.d.ts` sits beside it, so the import found nothing (3.0.0).
|
|
6
9
|
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
10
|
import { createRequire } from "node:module";
|
|
8
11
|
import { tmpdir } from "node:os";
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
width: 100%;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/* A value axis owned by one series wears its colour. Mixed toward the foreground like the
|
|
19
|
+
figure above it, since a line colour clears 3:1 and 11px text needs 4.5:1. */
|
|
20
|
+
.hr-area-chart .hr-axis-tone[style*="--hr-axis-tone"] .lc-axis-tick-label {
|
|
21
|
+
fill: color-mix(in oklab, var(--hr-axis-tone) 70%, var(--foreground, oklch(0.2 0.01 286)));
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
.hr-area-chart .empty {
|
|
19
25
|
display: flex;
|
|
20
26
|
align-items: center;
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { resolveEdgeFade } from "./edge-fade.js";
|
|
24
24
|
import Figure from "./figure.svelte";
|
|
25
25
|
import HoverFocus from "./hover-focus.svelte";
|
|
26
|
+
import { dualAxis } from "./dual-axis.js";
|
|
26
27
|
import { DOMAIN_MOTION, TOOLTIP_GAP, TOOLTIP_TOP } from "./motion.js";
|
|
27
28
|
import { valueTick } from "./primitives/chart-utils.js";
|
|
28
29
|
import Reveal from "./reveal.svelte";
|
|
@@ -44,6 +45,11 @@
|
|
|
44
45
|
key: keyof TData & string;
|
|
45
46
|
label?: string;
|
|
46
47
|
color?: string;
|
|
48
|
+
/**
|
|
49
|
+
* `"right"` plots the series on a second value axis with its own scale, for a measure in a
|
|
50
|
+
* different unit or about 10x larger than the rest. It draws as a line, without the fill.
|
|
51
|
+
*/
|
|
52
|
+
axis?: "left" | "right";
|
|
47
53
|
};
|
|
48
54
|
|
|
49
55
|
interface Props {
|
|
@@ -68,8 +74,15 @@
|
|
|
68
74
|
xTicks?: number;
|
|
69
75
|
/** A target drawn across the plot, like the deal a cost runs up to. */
|
|
70
76
|
reference?: ChartReference;
|
|
71
|
-
/**
|
|
72
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The headline while no point is hovered, like a period total. Defaults to the last point.
|
|
79
|
+
* `values`, keyed by series, shows one figure per series side by side in its colour, so it also reads as the legend.
|
|
80
|
+
*/
|
|
81
|
+
resting?: {
|
|
82
|
+
value?: number;
|
|
83
|
+
values?: Partial<Record<keyof TData & string, number>>;
|
|
84
|
+
label: string;
|
|
85
|
+
};
|
|
73
86
|
/** Smooths the drawn line by the window shown; the resting figure stays the caller's total. */
|
|
74
87
|
smoothing?: Smoothing;
|
|
75
88
|
/** The chart's accessible name. Its data range and values are read after it. */
|
|
@@ -146,11 +159,14 @@
|
|
|
146
159
|
key: s.key,
|
|
147
160
|
label: s.label ?? s.key,
|
|
148
161
|
color: s.color ?? `var(--chart-series-${(i % 6) + 1})`,
|
|
162
|
+
axis: s.axis === "right" ? ("right" as const) : ("left" as const),
|
|
149
163
|
}))
|
|
150
164
|
: yKey
|
|
151
|
-
? [{ key: yKey, label: yKey, color }]
|
|
165
|
+
? [{ key: yKey, label: yKey, color, axis: "left" as const }]
|
|
152
166
|
: [],
|
|
153
167
|
);
|
|
168
|
+
const leftSeries = $derived(series.filter((s) => s.axis === "left"));
|
|
169
|
+
const rightSeries = $derived(series.filter((s) => s.axis === "right"));
|
|
154
170
|
const smoothed = $derived(
|
|
155
171
|
smoothing
|
|
156
172
|
? smoothSeries(
|
|
@@ -167,12 +183,41 @@
|
|
|
167
183
|
const averageLabel = $derived(mode === "rolling" ? averageName(smoothed.window) : null);
|
|
168
184
|
const dataMax = $derived.by(() => {
|
|
169
185
|
if (stacked) {
|
|
170
|
-
return Math.max(
|
|
186
|
+
return Math.max(
|
|
187
|
+
0,
|
|
188
|
+
...data.map((d) => leftSeries.reduce((s, k) => s + Number(d[k.key] ?? 0), 0)),
|
|
189
|
+
);
|
|
171
190
|
}
|
|
172
|
-
return Math.max(0, ...data.flatMap((d) =>
|
|
191
|
+
return Math.max(0, ...data.flatMap((d) => leftSeries.map((s) => Number(d[s.key] ?? 0))));
|
|
173
192
|
});
|
|
174
193
|
// A target above every point would sit off the plot.
|
|
175
194
|
const yMax = $derived(Math.max(dataMax, reference?.value ?? 0));
|
|
195
|
+
// A right-axis series lives on the chart's one y scale, rescaled so its domain fills the same
|
|
196
|
+
// height; the right axis labels that height in the series' own unit.
|
|
197
|
+
const axes = $derived.by(() => {
|
|
198
|
+
if (rightSeries.length === 0) return null;
|
|
199
|
+
const rightMax = Math.max(
|
|
200
|
+
0,
|
|
201
|
+
...data.flatMap((d) => rightSeries.map((s) => Number(d[s.key] ?? 0))),
|
|
202
|
+
);
|
|
203
|
+
return dualAxis(yMax, rightMax);
|
|
204
|
+
});
|
|
205
|
+
const plotKey = (s: { key: string; axis: "left" | "right" }) =>
|
|
206
|
+
s.axis === "right" ? `__hr_right_${s.key}` : s.key;
|
|
207
|
+
const plotData = $derived.by((): TData[] => {
|
|
208
|
+
if (!axes) return data;
|
|
209
|
+
const factor = axes.left.max / axes.right.max;
|
|
210
|
+
return data.map((d) => {
|
|
211
|
+
const row: Record<string, unknown> = { ...d };
|
|
212
|
+
// A gap stays a gap: null times a factor would draw a 0.
|
|
213
|
+
for (const s of rightSeries)
|
|
214
|
+
row[plotKey(s)] = d[s.key] == null ? null : Number(d[s.key]) * factor;
|
|
215
|
+
return row as TData;
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
// An axis takes its series' colour only when one series owns it; with two on a side the colour would name one of them.
|
|
219
|
+
const leftTone = $derived(axes && leftSeries.length === 1 ? leftSeries[0].color : undefined);
|
|
220
|
+
const rightTone = $derived(rightSeries.length === 1 ? rightSeries[0].color : undefined);
|
|
176
221
|
const curve = $derived(smooth ? curveMonotoneX : undefined);
|
|
177
222
|
const fade = $derived(resolveEdgeFade(edgeFade));
|
|
178
223
|
const fadeMask = $derived(fade > 0 ? `url(#${uid}-fade)` : undefined);
|
|
@@ -206,7 +251,16 @@
|
|
|
206
251
|
<!-- WHY: leaving only resets the hover readout; the keyboard path is ChartPlot's. -->
|
|
207
252
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
208
253
|
<div class="hr-area-chart {className ?? ''}" onpointerleave={() => (active = null)}>
|
|
209
|
-
{#if resting && !active
|
|
254
|
+
{#if resting?.values && !active}
|
|
255
|
+
<Figure
|
|
256
|
+
series={series
|
|
257
|
+
.filter((s) => resting.values?.[s.key] != null)
|
|
258
|
+
.map((s) => ({ value: Number(resting.values?.[s.key]), label: s.label, color: s.color }))}
|
|
259
|
+
eyebrow={resting.label}
|
|
260
|
+
format={formatValue}
|
|
261
|
+
size="md"
|
|
262
|
+
/>
|
|
263
|
+
{:else if resting && resting.value != null && !active && series[0]}
|
|
210
264
|
<Figure
|
|
211
265
|
series={[{ value: resting.value, label: series[0].label, color: series[0].color }]}
|
|
212
266
|
eyebrow={resting.label}
|
|
@@ -226,21 +280,43 @@
|
|
|
226
280
|
<div class="empty"><p>No data</p></div>
|
|
227
281
|
{:else}
|
|
228
282
|
<Chart
|
|
229
|
-
{
|
|
283
|
+
data={plotData}
|
|
230
284
|
x={xKey}
|
|
231
|
-
y={series[0]
|
|
232
|
-
yDomain={[0, yMax || 1]}
|
|
233
|
-
yNice
|
|
234
|
-
padding={{ left: 48, bottom: 28, top: 8, right: 12 }}
|
|
285
|
+
y={series[0] ? plotKey(series[0]) : undefined}
|
|
286
|
+
yDomain={[0, axes ? axes.left.max : yMax || 1]}
|
|
287
|
+
yNice={!axes}
|
|
288
|
+
padding={{ left: 48, bottom: 28, top: 8, right: axes ? 48 : 12 }}
|
|
235
289
|
tooltipContext={{ mode: "quadtree-x" }}
|
|
236
290
|
motion={DOMAIN_MOTION}
|
|
237
291
|
bind:context
|
|
238
292
|
>
|
|
239
293
|
<Svg aria-hidden="true">
|
|
240
|
-
<
|
|
294
|
+
<g class="hr-axis-tone" style:--hr-axis-tone={leftTone}>
|
|
295
|
+
<Axis
|
|
296
|
+
placement="left"
|
|
297
|
+
format={(v) => tick(Number(v))}
|
|
298
|
+
ticks={axes ? axes.left.ticks : 4}
|
|
299
|
+
/>
|
|
300
|
+
</g>
|
|
301
|
+
{#if axes}
|
|
302
|
+
<g class="hr-axis-tone" style:--hr-axis-tone={rightTone}>
|
|
303
|
+
<Axis
|
|
304
|
+
placement="right"
|
|
305
|
+
domain={[0, axes.right.max]}
|
|
306
|
+
format={(v) => tick(Number(v))}
|
|
307
|
+
ticks={axes.right.ticks}
|
|
308
|
+
/>
|
|
309
|
+
</g>
|
|
310
|
+
{/if}
|
|
241
311
|
<Axis placement="bottom" format={(v) => formatX(v)} ticks={ticksForWidth(xTicks)} />
|
|
242
312
|
<g class="hr-area-grid">
|
|
243
|
-
<Axis
|
|
313
|
+
<Axis
|
|
314
|
+
placement="left"
|
|
315
|
+
grid
|
|
316
|
+
rule={false}
|
|
317
|
+
ticks={axes ? axes.left.ticks : 4}
|
|
318
|
+
format={() => ""}
|
|
319
|
+
/>
|
|
244
320
|
</g>
|
|
245
321
|
{#if fade > 0}
|
|
246
322
|
<EdgeFade id="{uid}-fade" amount={fade} />
|
|
@@ -261,17 +337,39 @@
|
|
|
261
337
|
<!-- WHY `y1` and not `y`: layerchart's Area has no `y` prop, so every
|
|
262
338
|
series drew the first series' shape and the gradients stacked on
|
|
263
339
|
one path. See `Area.shared.svelte.js`. -->
|
|
264
|
-
|
|
265
|
-
|
|
340
|
+
{#if s.axis === "left"}
|
|
341
|
+
<Area y1={s.key} {curve} fill="url(#{uid}-{i})" mask={fadeMask} />
|
|
342
|
+
{/if}
|
|
343
|
+
<Spline
|
|
344
|
+
y={plotKey(s)}
|
|
345
|
+
{curve}
|
|
346
|
+
style="stroke: {s.color}; fill: none"
|
|
347
|
+
stroke-width="2"
|
|
348
|
+
/>
|
|
266
349
|
{/each}
|
|
267
350
|
</Reveal>
|
|
268
351
|
{#snippet segment()}
|
|
269
352
|
{#each series as s (s.key)}
|
|
270
|
-
<Spline
|
|
353
|
+
<Spline
|
|
354
|
+
y={plotKey(s)}
|
|
355
|
+
{curve}
|
|
356
|
+
style="stroke: {s.color}; fill: none"
|
|
357
|
+
stroke-width="2"
|
|
358
|
+
/>
|
|
271
359
|
{/each}
|
|
272
360
|
{/snippet}
|
|
273
361
|
</HoverFocus>
|
|
274
|
-
|
|
362
|
+
{#if axes}
|
|
363
|
+
<!-- WHY `data`: without it Highlight places dots from the tooltip's raw values, off the rescaled right series. -->
|
|
364
|
+
<Highlight lines={{ style: `stroke: url(#${uid}-cross)` }} />
|
|
365
|
+
{#if context?.tooltip.data}
|
|
366
|
+
{#each series as s (s.key)}
|
|
367
|
+
<Highlight data={context.tooltip.data} y={plotKey(s)} points={{ fill: s.color }} />
|
|
368
|
+
{/each}
|
|
369
|
+
{/if}
|
|
370
|
+
{:else}
|
|
371
|
+
<Highlight points lines={{ style: `stroke: url(#${uid}-cross)` }} />
|
|
372
|
+
{/if}
|
|
275
373
|
</Svg>
|
|
276
374
|
<Tooltip.Root x="data" y={TOOLTIP_TOP} xOffset={TOOLTIP_GAP}>
|
|
277
375
|
{#snippet children({ data: d })}
|
|
@@ -11,6 +11,11 @@ declare function $$render<TData extends Record<string, unknown>>(): {
|
|
|
11
11
|
key: keyof TData & string;
|
|
12
12
|
label?: string;
|
|
13
13
|
color?: string;
|
|
14
|
+
/**
|
|
15
|
+
* `"right"` plots the series on a second value axis with its own scale, for a measure in a
|
|
16
|
+
* different unit or about 10x larger than the rest. It draws as a line, without the fill.
|
|
17
|
+
*/
|
|
18
|
+
axis?: "left" | "right";
|
|
14
19
|
}[];
|
|
15
20
|
color?: string;
|
|
16
21
|
height?: number;
|
|
@@ -25,9 +30,13 @@ declare function $$render<TData extends Record<string, unknown>>(): {
|
|
|
25
30
|
xTicks?: number;
|
|
26
31
|
/** A target drawn across the plot, like the deal a cost runs up to. */
|
|
27
32
|
reference?: ChartReference;
|
|
28
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* The headline while no point is hovered, like a period total. Defaults to the last point.
|
|
35
|
+
* `values`, keyed by series, shows one figure per series side by side in its colour, so it also reads as the legend.
|
|
36
|
+
*/
|
|
29
37
|
resting?: {
|
|
30
|
-
value
|
|
38
|
+
value?: number;
|
|
39
|
+
values?: Partial<Record<keyof TData & string, number>>;
|
|
31
40
|
label: string;
|
|
32
41
|
};
|
|
33
42
|
/** Smooths the drawn line by the window shown; the resting figure stays the caller's total. */
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
`@heyramzi/ui/charts/dashboard` keeps its card edge without the barrel. */
|
|
5
5
|
|
|
6
6
|
@layer components {
|
|
7
|
-
/* Shared chart tokens, held at zero specificity by `:where()`. The
|
|
8
|
-
|
|
7
|
+
/* Shared chart tokens, held at zero specificity by `:where()`. The tooltip portals to
|
|
8
|
+
`body`, outside its chart, so it is listed for its series dots to match the lines. */
|
|
9
9
|
:where(
|
|
10
10
|
.hr-area-chart,
|
|
11
11
|
.hr-bar-chart,
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
.hr-ts-chart,
|
|
18
18
|
.hr-donut-chart,
|
|
19
19
|
.hr-ring,
|
|
20
|
-
.hr-gauge
|
|
20
|
+
.hr-gauge,
|
|
21
|
+
.lc-tooltip-container
|
|
21
22
|
) {
|
|
22
23
|
/* Series palette: brand, then hues round the wheel, blue and orange first for colour
|
|
23
24
|
blindness. Each is pulled 15% toward the text, clearing 3:1 on the card in both themes. */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { dualAxis, niceStep } from "./dual-axis.js";
|
|
3
|
+
|
|
4
|
+
describe("niceStep", () => {
|
|
5
|
+
it("rounds up to 1, 2, 2.5 or 5 times a power of ten", () => {
|
|
6
|
+
expect(niceStep(40)).toBe(50);
|
|
7
|
+
expect(niceStep(1500)).toBe(2000);
|
|
8
|
+
expect(niceStep(2100)).toBe(2500);
|
|
9
|
+
expect(niceStep(0.3)).toBeCloseTo(0.5);
|
|
10
|
+
expect(niceStep(0)).toBe(1);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("dualAxis", () => {
|
|
15
|
+
it("gives clicks and impressions the same number of grid lines", () => {
|
|
16
|
+
const { left, right } = dualAxis(160, 6000);
|
|
17
|
+
expect(left).toEqual({ max: 200, ticks: [0, 50, 100, 150, 200] });
|
|
18
|
+
expect(right).toEqual({ max: 8000, ticks: [0, 2000, 4000, 6000, 8000] });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps every point inside both domains", () => {
|
|
22
|
+
for (const [l, r] of [
|
|
23
|
+
[7, 9_999],
|
|
24
|
+
[123_456, 3],
|
|
25
|
+
[1, 1],
|
|
26
|
+
[0.42, 87],
|
|
27
|
+
]) {
|
|
28
|
+
const { left, right } = dualAxis(l, r);
|
|
29
|
+
expect(left.max).toBeGreaterThanOrEqual(l);
|
|
30
|
+
expect(right.max).toBeGreaterThanOrEqual(r);
|
|
31
|
+
expect(left.ticks.length).toBe(right.ticks.length);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("draws one interval for an empty series instead of dividing by zero", () => {
|
|
36
|
+
const { left, right } = dualAxis(0, 0);
|
|
37
|
+
expect(left.ticks).toEqual([0, 1]);
|
|
38
|
+
expect(right.ticks).toEqual([0, 1]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two value axes that share one set of grid lines.
|
|
3
|
+
*
|
|
4
|
+
* A right axis with its own nice ticks lands its labels between the left axis's grid lines, so a
|
|
5
|
+
* reader can't trace either. Both axes here get the same number of intervals: the left picks a nice
|
|
6
|
+
* step for its data, and the right picks the smallest nice step that fits its data in that many.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type AxisScale = { max: number; ticks: number[] };
|
|
10
|
+
|
|
11
|
+
const NICE = [1, 2, 2.5, 5, 10];
|
|
12
|
+
|
|
13
|
+
/** The smallest 1, 2, 2.5 or 5 times a power of ten that is at least `raw`. */
|
|
14
|
+
export function niceStep(raw: number): number {
|
|
15
|
+
if (!(raw > 0) || !Number.isFinite(raw)) return 1;
|
|
16
|
+
const power = 10 ** Math.floor(Math.log10(raw));
|
|
17
|
+
// WHY the epsilon: 0.3 / 0.1 is 2.9999999999999996, which would skip 3 x 0.1 for the next step.
|
|
18
|
+
const multiple = NICE.find((m) => m * power >= raw * (1 - 1e-9)) ?? 10;
|
|
19
|
+
return multiple * power;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ticksOf(step: number, count: number): number[] {
|
|
23
|
+
// Rounded so 0.1 x 3 reads 0.3 on the axis, not 0.30000000000000004.
|
|
24
|
+
return Array.from({ length: count + 1 }, (_, i) => Number((i * step).toPrecision(12)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Left and right domains from 0, with the same tick count so both axes sit on one grid.
|
|
29
|
+
* `target` is the number of intervals wanted; the left data may round it to one more or less.
|
|
30
|
+
*/
|
|
31
|
+
export function dualAxis(
|
|
32
|
+
leftMax: number,
|
|
33
|
+
rightMax: number,
|
|
34
|
+
target = 4,
|
|
35
|
+
): { left: AxisScale; right: AxisScale } {
|
|
36
|
+
const leftStep = niceStep(Math.max(0, leftMax) / target);
|
|
37
|
+
const count = Math.max(1, Math.ceil(Math.max(0, leftMax) / leftStep - 1e-9));
|
|
38
|
+
const rightStep = niceStep(Math.max(0, rightMax) / count);
|
|
39
|
+
return {
|
|
40
|
+
left: { max: leftStep * count, ticks: ticksOf(leftStep, count) },
|
|
41
|
+
right: { max: rightStep * count, ticks: ticksOf(rightStep, count) },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -58,6 +58,14 @@
|
|
|
58
58
|
// =========================
|
|
59
59
|
// FUNCTIONS
|
|
60
60
|
// =========================
|
|
61
|
+
/**
|
|
62
|
+
* Active state for a toolbar button. `editor` is the same object every transaction, so a
|
|
63
|
+
* derived of it never re-runs the template; reading `tick` here does.
|
|
64
|
+
*/
|
|
65
|
+
function isActive(name: string, attributes?: Record<string, unknown>): boolean {
|
|
66
|
+
return tick >= 0 && (editor?.isActive(name, attributes) ?? false);
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
/** Opens the link field, or removes the link under the cursor. The editor binds Cmd+K to it. */
|
|
62
70
|
export function toggleLink() {
|
|
63
71
|
if (!editor) return;
|
|
@@ -128,7 +136,7 @@
|
|
|
128
136
|
size="sm"
|
|
129
137
|
icon
|
|
130
138
|
class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand font-heading text-xs font-bold"
|
|
131
|
-
aria-pressed={
|
|
139
|
+
aria-pressed={isActive("heading", { level: 1 })}
|
|
132
140
|
onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
133
141
|
title="Heading 1"
|
|
134
142
|
>
|
|
@@ -139,7 +147,7 @@
|
|
|
139
147
|
size="sm"
|
|
140
148
|
icon
|
|
141
149
|
class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand font-heading text-xs font-bold"
|
|
142
|
-
aria-pressed={
|
|
150
|
+
aria-pressed={isActive("heading", { level: 2 })}
|
|
143
151
|
onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
144
152
|
title="Heading 2"
|
|
145
153
|
>
|
|
@@ -150,7 +158,7 @@
|
|
|
150
158
|
size="sm"
|
|
151
159
|
icon
|
|
152
160
|
class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand font-heading text-xs font-bold"
|
|
153
|
-
aria-pressed={
|
|
161
|
+
aria-pressed={isActive("heading", { level: 3 })}
|
|
154
162
|
onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
155
163
|
title="Heading 3"
|
|
156
164
|
>
|
|
@@ -164,7 +172,7 @@
|
|
|
164
172
|
size="sm"
|
|
165
173
|
icon
|
|
166
174
|
class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
|
|
167
|
-
aria-pressed={
|
|
175
|
+
aria-pressed={isActive("bold")}
|
|
168
176
|
onclick={() => editor?.chain().focus().toggleBold().run()}
|
|
169
177
|
title="Bold (Cmd+B)"
|
|
170
178
|
>
|
|
@@ -175,7 +183,7 @@
|
|
|
175
183
|
size="sm"
|
|
176
184
|
icon
|
|
177
185
|
class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
|
|
178
|
-
aria-pressed={
|
|
186
|
+
aria-pressed={isActive("italic")}
|
|
179
187
|
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
|
180
188
|
title="Italic (Cmd+I)"
|
|
181
189
|
>
|
|
@@ -186,7 +194,7 @@
|
|
|
186
194
|
size="sm"
|
|
187
195
|
icon
|
|
188
196
|
class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
|
|
189
|
-
aria-pressed={
|
|
197
|
+
aria-pressed={isActive("strike")}
|
|
190
198
|
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
|
191
199
|
title="Strikethrough"
|
|
192
200
|
>
|
|
@@ -195,7 +203,7 @@
|
|
|
195
203
|
|
|
196
204
|
<div class="toolbar-divider"></div>
|
|
197
205
|
|
|
198
|
-
{#if
|
|
206
|
+
{#if isActive("link")}
|
|
199
207
|
<Button
|
|
200
208
|
variant="ghost"
|
|
201
209
|
size="sm"
|
|
@@ -227,7 +235,7 @@
|
|
|
227
235
|
size="sm"
|
|
228
236
|
icon
|
|
229
237
|
class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
|
|
230
|
-
aria-pressed={
|
|
238
|
+
aria-pressed={isActive("bulletList")}
|
|
231
239
|
onclick={() => editor?.chain().focus().toggleBulletList().run()}
|
|
232
240
|
title="Bullet list"
|
|
233
241
|
>
|
|
@@ -238,7 +246,7 @@
|
|
|
238
246
|
size="sm"
|
|
239
247
|
icon
|
|
240
248
|
class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
|
|
241
|
-
aria-pressed={
|
|
249
|
+
aria-pressed={isActive("orderedList")}
|
|
242
250
|
onclick={() => editor?.chain().focus().toggleOrderedList().run()}
|
|
243
251
|
title="Ordered list"
|
|
244
252
|
>
|
|
@@ -249,7 +257,7 @@
|
|
|
249
257
|
size="sm"
|
|
250
258
|
icon
|
|
251
259
|
class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
|
|
252
|
-
aria-pressed={
|
|
260
|
+
aria-pressed={isActive("blockquote")}
|
|
253
261
|
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
|
254
262
|
title="Quote"
|
|
255
263
|
>
|
|
@@ -264,7 +272,7 @@
|
|
|
264
272
|
size="sm"
|
|
265
273
|
icon
|
|
266
274
|
class="text-muted-foreground aria-pressed:bg-brand/10 aria-pressed:text-brand"
|
|
267
|
-
aria-pressed={
|
|
275
|
+
aria-pressed={isActive("youtube")}
|
|
268
276
|
onclick={onInsertYoutube}
|
|
269
277
|
title="Insert YouTube video"
|
|
270
278
|
>
|
package/src/editor/editor.svelte
CHANGED
|
@@ -388,7 +388,7 @@
|
|
|
388
388
|
<!-- =========================== -->
|
|
389
389
|
<div
|
|
390
390
|
class="editor-wrapper"
|
|
391
|
-
class:focused={editor?.isFocused && !isPage}
|
|
391
|
+
class:focused={tick >= 0 && editor?.isFocused && !isPage}
|
|
392
392
|
class:page={isPage}
|
|
393
393
|
class:doc-surface={isPage}
|
|
394
394
|
style="--editor-min-height: {minHeight};"
|
package/src/slides/Deck.svelte
CHANGED
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
return labels;
|
|
73
73
|
},
|
|
74
74
|
transition: viewTransition,
|
|
75
|
+
zoomMs,
|
|
75
76
|
});
|
|
76
77
|
controller = deck;
|
|
77
78
|
setContext(DECK_KEY, deck);
|
|
@@ -88,6 +89,14 @@
|
|
|
88
89
|
run.finished.finally(() => delete document.documentElement.dataset.deckVt);
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/* The overview zoom's length, read from the CSS so it follows the token. 0 under reduced motion. */
|
|
93
|
+
function zoomMs() {
|
|
94
|
+
if (!root || matchMedia("(prefers-reduced-motion: reduce)").matches) return 0;
|
|
95
|
+
const value = getComputedStyle(root).getPropertyValue("--deck-t-zoom").trim();
|
|
96
|
+
const ms = Number.parseFloat(value) * (value.endsWith("ms") ? 1 : 1000);
|
|
97
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
91
100
|
onMount(() => deck.mount());
|
|
92
101
|
|
|
93
102
|
$effect(() => deck.listen((position) => onIndexChange?.(position.index, position.step)));
|
|
@@ -175,7 +184,7 @@
|
|
|
175
184
|
data-deck={id}
|
|
176
185
|
data-view={deck.view}
|
|
177
186
|
data-transition={transition}
|
|
178
|
-
data-
|
|
187
|
+
data-zoom={deck.zoom ? (deck.zoom.running ? "run" : "hold") : undefined}
|
|
179
188
|
{onpointerdown}
|
|
180
189
|
{onpointerup}
|
|
181
190
|
>
|
|
@@ -185,7 +194,8 @@
|
|
|
185
194
|
{:else}
|
|
186
195
|
{@render children?.()}
|
|
187
196
|
{/if}
|
|
188
|
-
|
|
197
|
+
<!-- The tiles mount once the camera starts pulling back, and fade in as it lands. -->
|
|
198
|
+
{#if deck.overview && !deck.zoom?.framed}
|
|
189
199
|
<Overview {deck} returnFocus={root} />
|
|
190
200
|
{/if}
|
|
191
201
|
</div>
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
padding: 0;
|
|
61
61
|
list-style: none;
|
|
62
62
|
/* The ring and number appear once the thumbnails have landed. */
|
|
63
|
-
animation: tiles-in var(--deck-t-quick) linear var(--deck-t-
|
|
63
|
+
animation: tiles-in var(--deck-t-quick) linear var(--deck-t-zoom) backwards;
|
|
64
64
|
}
|
|
65
65
|
@media (prefers-reduced-motion: reduce) {
|
|
66
66
|
.deck__tiles {
|
|
@@ -94,14 +94,20 @@
|
|
|
94
94
|
.deck__tile:focus-visible {
|
|
95
95
|
outline-color: var(--deck-fg);
|
|
96
96
|
}
|
|
97
|
+
/*
|
|
98
|
+
* The number sits in the gap under its thumbnail, never on it: straddling the edge put
|
|
99
|
+
* half of it under the slide. 7px clears the 5px ring, and 7 + 18 fits the grid's 26px gap.
|
|
100
|
+
*/
|
|
97
101
|
.deck__tile-n {
|
|
98
102
|
position: absolute;
|
|
99
|
-
|
|
103
|
+
top: calc(100% + 7px);
|
|
100
104
|
left: 50%;
|
|
101
|
-
|
|
105
|
+
z-index: 1;
|
|
106
|
+
box-sizing: border-box;
|
|
107
|
+
min-width: 24px;
|
|
102
108
|
transform: translateX(-50%);
|
|
103
109
|
padding: 3px 6px;
|
|
104
|
-
font: 600
|
|
110
|
+
font: 600 12px/1 var(--deck-font);
|
|
105
111
|
font-variant-numeric: tabular-nums;
|
|
106
112
|
text-align: center;
|
|
107
113
|
color: var(--deck-fg);
|
package/src/slides/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Keys: arrows, Space (Shift+Space back), PageUp/PageDown, Home/End, `O` overview,
|
|
|
43
43
|
|
|
44
44
|
## Theming
|
|
45
45
|
|
|
46
|
-
Set variables on `.deck`: `--deck-bg`, `--deck-fg`, `--deck-accent`, `--deck-on-accent`, `--deck-font`, `--deck-font-display`, `--deck-pad`, `--deck-text-*` (design px) and the timing tokens `--deck-t-slide`, `--deck-t-morph`, `--deck-stagger`, `--deck-ease`. They default to the `@heyramzi/ui` motion tokens when present. Motion is transform and opacity only and stops under `prefers-reduced-motion`.
|
|
46
|
+
Set variables on `.deck`: `--deck-bg`, `--deck-fg`, `--deck-accent`, `--deck-on-accent`, `--deck-font`, `--deck-font-display`, `--deck-pad`, `--deck-text-*` (design px) and the timing tokens `--deck-t-slide`, `--deck-t-morph`, `--deck-t-zoom` (the overview zoom), `--deck-stagger`, `--deck-ease`. They default to the `@heyramzi/ui` motion tokens when present. `--deck-accent` and `--deck-on-accent` follow the app's `--primary` and `--primary-foreground`, and fall back to the deck's ink. Motion is transform and opacity only and stops under `prefers-reduced-motion`.
|
|
47
47
|
|
|
48
48
|
## Data-driven decks
|
|
49
49
|
|
package/src/slides/Slide.svelte
CHANGED
|
@@ -152,19 +152,19 @@
|
|
|
152
152
|
transition-delay: 0s;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
/*
|
|
156
|
-
|
|
155
|
+
/*
|
|
156
|
+
* The overview shows every slide. Opening and closing move them as one rigid grid, like a
|
|
157
|
+
* camera pulling back or pushing in, so no slide gets its own delay.
|
|
158
|
+
*/
|
|
159
|
+
:global(.deck[data-view="overview"]) .slide,
|
|
160
|
+
:global(.deck[data-zoom]) .slide {
|
|
157
161
|
visibility: visible;
|
|
162
|
+
transition-delay: 0s;
|
|
158
163
|
}
|
|
159
|
-
:global(.deck[data-
|
|
164
|
+
:global(.deck[data-zoom="run"]) .slide {
|
|
160
165
|
transition:
|
|
161
|
-
visibility 0s
|
|
162
|
-
transform
|
|
163
|
-
var(--deck-ease);
|
|
164
|
-
}
|
|
165
|
-
/* Opening shows the slides at once, so only closing waits before hiding them. */
|
|
166
|
-
:global(.deck[data-morphing][data-view="overview"]) .slide {
|
|
167
|
-
transition-delay: 0s;
|
|
166
|
+
visibility 0s,
|
|
167
|
+
transform var(--deck-t-zoom) var(--deck-ease);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
.slide__surface {
|
|
@@ -178,11 +178,17 @@
|
|
|
178
178
|
font-family: var(--deck-font);
|
|
179
179
|
animation: var(--deck-t-slide) var(--deck-ease) backwards;
|
|
180
180
|
}
|
|
181
|
-
|
|
181
|
+
/* Framed on one slide the grid looks like that slide, so it keeps a slide's square corners. */
|
|
182
|
+
:global(.deck[data-view="overview"]:not([data-zoom="hold"])) .slide__surface {
|
|
182
183
|
/* Design px, so divide by the scale to get the same corner on every thumbnail. */
|
|
183
184
|
border-radius: calc(10px / var(--slide-scale, 1));
|
|
184
185
|
box-shadow: 0 0 0 calc(1px / var(--slide-scale, 1)) var(--deck-line);
|
|
185
186
|
}
|
|
187
|
+
:global(.deck[data-zoom="run"]) .slide__surface {
|
|
188
|
+
transition:
|
|
189
|
+
border-radius var(--deck-t-zoom) var(--deck-ease),
|
|
190
|
+
box-shadow var(--deck-t-zoom) var(--deck-ease);
|
|
191
|
+
}
|
|
186
192
|
|
|
187
193
|
.slide__surface[data-align="center"],
|
|
188
194
|
.slide__surface[data-align="start"] {
|
|
@@ -44,16 +44,34 @@ export type ControllerOptions = {
|
|
|
44
44
|
labels?: Partial<DeckLabels>;
|
|
45
45
|
/** Wraps a position change so a View Transition can snapshot around it. */
|
|
46
46
|
transition?: (update: () => void) => void;
|
|
47
|
+
/** How long the overview zoom plays, in ms. 0, or left out, swaps at once. */
|
|
48
|
+
zoomMs?: () => number;
|
|
47
49
|
};
|
|
48
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The overview zoom while it plays. `index` is the slide the camera frames. `framed` puts that
|
|
53
|
+
* slide in its full-size spot with the grid around it, and `running` lets the boxes transition.
|
|
54
|
+
*/
|
|
55
|
+
export type Zoom = { index: number; framed: boolean; running: boolean };
|
|
56
|
+
|
|
49
57
|
/** Height the controls bar takes at the bottom of the overview, in px. */
|
|
50
58
|
const CONTROLS_ROOM = 56;
|
|
51
59
|
|
|
52
|
-
/** How long the overview morph flag stays up, a little past the longest stagger. */
|
|
53
|
-
const MORPH_MS = 900;
|
|
54
|
-
|
|
55
60
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
|
56
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Where `cell` lands when the camera moves `target` onto `fit`. Every box shares one scale and
|
|
64
|
+
* shift, and a transform tween is linear in both, so the grid stays rigid while it zooms.
|
|
65
|
+
*/
|
|
66
|
+
function frame(cell: Box, target: Box, fit: Box): Box {
|
|
67
|
+
const k = fit.scale / target.scale;
|
|
68
|
+
return {
|
|
69
|
+
x: fit.x + (cell.x - target.x) * k,
|
|
70
|
+
y: fit.y + (cell.y - target.y) * k,
|
|
71
|
+
scale: cell.scale * k,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
function isEditable(el: HTMLElement) {
|
|
58
76
|
return el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
|
|
59
77
|
}
|
|
@@ -71,12 +89,13 @@ export class DeckController {
|
|
|
71
89
|
/** The highlighted thumbnail in the overview, as an index among the visible slides. */
|
|
72
90
|
cursor = $state(0);
|
|
73
91
|
direction = $state<1 | -1>(1);
|
|
74
|
-
|
|
92
|
+
zoom = $state<Zoom | null>(null);
|
|
75
93
|
size = $state<Size>({ w: 0, h: 0 });
|
|
76
94
|
|
|
77
95
|
readonly options!: ControllerOptions;
|
|
78
96
|
#listeners = new Set<Listener>();
|
|
79
97
|
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
98
|
+
#frame = 0;
|
|
80
99
|
|
|
81
100
|
constructor(options: ControllerOptions) {
|
|
82
101
|
this.options = options;
|
|
@@ -116,8 +135,13 @@ export class DeckController {
|
|
|
116
135
|
|
|
117
136
|
/** Where slide `index` sits right now: centred to fit, or in its overview cell. */
|
|
118
137
|
boxOf(index: number): Box | undefined {
|
|
119
|
-
|
|
120
|
-
|
|
138
|
+
const zoom = this.zoom;
|
|
139
|
+
const cell = this.grid.boxes[this.visible.indexOf(index)];
|
|
140
|
+
if (zoom?.framed) {
|
|
141
|
+
const target = this.grid.boxes[this.visible.indexOf(zoom.index)];
|
|
142
|
+
return cell && target ? frame(cell, target, this.fit) : this.fit;
|
|
143
|
+
}
|
|
144
|
+
return this.overview || zoom ? cell : this.fit;
|
|
121
145
|
}
|
|
122
146
|
|
|
123
147
|
register(entry: SlideEntry): () => void {
|
|
@@ -137,7 +161,7 @@ export class DeckController {
|
|
|
137
161
|
}
|
|
138
162
|
|
|
139
163
|
mount(): () => void {
|
|
140
|
-
return () =>
|
|
164
|
+
return () => this.#stopZoom();
|
|
141
165
|
}
|
|
142
166
|
|
|
143
167
|
/** Which deck hears a key is the page's call: Deck passes only keys from focus inside it. */
|
|
@@ -150,14 +174,16 @@ export class DeckController {
|
|
|
150
174
|
this.options.step = position.step;
|
|
151
175
|
}
|
|
152
176
|
|
|
153
|
-
|
|
177
|
+
/** `animate: false` skips the slide transition, for a move the overview zoom already shows. */
|
|
178
|
+
commit(target: Position | null, source: Source = "local", animate = true) {
|
|
154
179
|
if (!target) return;
|
|
155
180
|
const from = this.position;
|
|
156
181
|
const to = settle(this.slides, target);
|
|
157
182
|
if (to.index === from.index && to.step === from.step) return;
|
|
158
183
|
this.direction = to.index >= from.index ? 1 : -1;
|
|
159
184
|
const apply = () => this.#write(to);
|
|
160
|
-
if (to.index !== from.index && this.options.transition)
|
|
185
|
+
if (animate && to.index !== from.index && this.options.transition)
|
|
186
|
+
this.options.transition(apply);
|
|
161
187
|
else apply();
|
|
162
188
|
for (const listener of this.#listeners) listener(to, source);
|
|
163
189
|
}
|
|
@@ -174,25 +200,45 @@ export class DeckController {
|
|
|
174
200
|
this.commit({ index, step }, source);
|
|
175
201
|
}
|
|
176
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Opening zooms the camera out from the current slide to the whole grid. Closing zooms into
|
|
205
|
+
* the current slide, so `select` moves there first.
|
|
206
|
+
*/
|
|
177
207
|
toggleOverview(force?: boolean) {
|
|
178
208
|
const open = force ?? !this.overview;
|
|
179
209
|
if (open === this.overview) return;
|
|
180
|
-
|
|
210
|
+
const index = this.position.index;
|
|
211
|
+
if (open) this.cursor = Math.max(0, this.visible.indexOf(index));
|
|
181
212
|
this.view = open ? "overview" : "slide";
|
|
182
|
-
this.#
|
|
213
|
+
this.#stopZoom();
|
|
214
|
+
const ms = this.options.zoomMs?.() ?? 0;
|
|
215
|
+
if (ms <= 0 || !this.visible.includes(index)) return;
|
|
216
|
+
|
|
217
|
+
/*
|
|
218
|
+
* Each way paints its start state first and moves on a later frame. Moved in the same
|
|
219
|
+
* flush, a style read in between (the overview handing focus back) commits the boxes
|
|
220
|
+
* before the transition rule is on, and the zoom jumps to its end.
|
|
221
|
+
*/
|
|
222
|
+
this.zoom = { index, framed: open, running: !open };
|
|
223
|
+
this.#frame = requestAnimationFrame(() => {
|
|
224
|
+
this.#frame = requestAnimationFrame(() => {
|
|
225
|
+
this.zoom = { index, framed: !open, running: true };
|
|
226
|
+
this.#timer = setTimeout(() => this.#stopZoom(), ms);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
183
229
|
}
|
|
184
230
|
|
|
185
231
|
/** Leaves the overview on slide `index`. Picking the current slide keeps its step. */
|
|
186
232
|
select(index: number) {
|
|
187
|
-
this.toggleOverview(false);
|
|
188
233
|
const keep = index === this.position.index;
|
|
189
|
-
this.
|
|
234
|
+
this.commit({ index, step: keep ? this.position.step : 0 }, "local", false);
|
|
235
|
+
this.toggleOverview(false);
|
|
190
236
|
}
|
|
191
237
|
|
|
192
|
-
#
|
|
193
|
-
this.morphing = true;
|
|
238
|
+
#stopZoom() {
|
|
194
239
|
clearTimeout(this.#timer);
|
|
195
|
-
this.#
|
|
240
|
+
cancelAnimationFrame(this.#frame);
|
|
241
|
+
this.zoom = null;
|
|
196
242
|
}
|
|
197
243
|
|
|
198
244
|
#moveCursor(to: number) {
|
package/src/slides/layout.ts
CHANGED
|
@@ -25,7 +25,8 @@ function scaleFor(
|
|
|
25
25
|
): number {
|
|
26
26
|
const rows = Math.ceil(count / cols);
|
|
27
27
|
const byWidth = (view.w - 2 * pad - (cols - 1) * gap) / cols / canvas.w;
|
|
28
|
-
|
|
28
|
+
/* Every row, the last too, keeps a gap under it for its numbers. */
|
|
29
|
+
const byHeight = (view.h - 2 * pad - rows * gap) / rows / canvas.h;
|
|
29
30
|
return Math.min(byWidth, byHeight);
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -47,7 +48,7 @@ export function gridBoxes(view: Size, canvas: Canvas, count: number): Grid {
|
|
|
47
48
|
const cellH = canvas.h * scale;
|
|
48
49
|
const rows = Math.ceil(count / cols);
|
|
49
50
|
const left = (view.w - (cols * cellW + (cols - 1) * gap)) / 2;
|
|
50
|
-
const top = (view.h -
|
|
51
|
+
const top = (view.h - rows * (cellH + gap)) / 2;
|
|
51
52
|
|
|
52
53
|
const boxes = Array.from({ length: count }, (_, i) => ({
|
|
53
54
|
x: left + (i % cols) * (cellW + gap),
|
package/src/slides/theme.css
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
/*
|
|
4
4
|
* Everything global to a deck, in design px since the whole canvas scales, and in flat
|
|
5
|
-
* hex because Chromium's PDF pipeline shifts oklch.
|
|
5
|
+
* hex because Chromium's PDF pipeline shifts oklch. The accent comes from the host app's
|
|
6
|
+
* `--primary`, which is often oklch, so it goes through `color-mix(in srgb)` to land as sRGB.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
@layer components {
|
|
@@ -12,8 +13,30 @@
|
|
|
12
13
|
|
|
13
14
|
--deck-bg: light-dark(#f2f1ec, #0e1014);
|
|
14
15
|
--deck-fg: light-dark(#15171b, #f3f1ea);
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
/* Without a `--primary` the accent is the deck's own ink, so nothing ships a colour the app never chose. */
|
|
17
|
+
/*
|
|
18
|
+
* `--primary` follows the page's theme and the deck sets its own scheme: a dark deck on a
|
|
19
|
+
* light page got the page's dark primary on a dark slide (2.7:1). The brand pair is picked
|
|
20
|
+
* by the deck's scheme; `--primary` is the fallback for an app without the kit palette.
|
|
21
|
+
*/
|
|
22
|
+
--deck-accent: color-mix(
|
|
23
|
+
in srgb,
|
|
24
|
+
light-dark(
|
|
25
|
+
var(--brand-primary, var(--primary, #15171b)),
|
|
26
|
+
var(--brand-primary-on-dark, var(--primary, #f3f1ea))
|
|
27
|
+
)
|
|
28
|
+
100%,
|
|
29
|
+
#000
|
|
30
|
+
);
|
|
31
|
+
--deck-on-accent: color-mix(
|
|
32
|
+
in srgb,
|
|
33
|
+
light-dark(
|
|
34
|
+
var(--brand-primary-foreground, var(--primary-foreground, #f6f6f0)),
|
|
35
|
+
var(--brand-primary-foreground-on-dark, var(--primary-foreground, #0e1014))
|
|
36
|
+
)
|
|
37
|
+
100%,
|
|
38
|
+
#000
|
|
39
|
+
);
|
|
17
40
|
--deck-muted: color-mix(in srgb, var(--deck-fg) 66%, transparent);
|
|
18
41
|
--deck-line: color-mix(in srgb, var(--deck-fg) 14%, transparent);
|
|
19
42
|
--deck-surface: color-mix(in srgb, var(--deck-fg) 6%, transparent);
|
|
@@ -32,6 +55,8 @@
|
|
|
32
55
|
|
|
33
56
|
--deck-t-slide: var(--duration-fast, 250ms);
|
|
34
57
|
--deck-t-morph: var(--duration-slow, 300ms);
|
|
58
|
+
/* The overview zoom moves the whole canvas, so it takes the overlay length, not the UI ceiling. */
|
|
59
|
+
--deck-t-zoom: var(--duration-overlay, 500ms);
|
|
35
60
|
--deck-t-quick: var(--duration-quick, 150ms);
|
|
36
61
|
--deck-stagger: var(--duration-stagger, 40ms);
|
|
37
62
|
--deck-ease: var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
@@ -124,8 +149,12 @@
|
|
|
124
149
|
animation-name: deck-zoom;
|
|
125
150
|
}
|
|
126
151
|
|
|
127
|
-
|
|
128
|
-
|
|
152
|
+
/*
|
|
153
|
+
* The zoom already shows the slide arriving, so its enter animation finishes at once. A
|
|
154
|
+
* 0s duration, not `animation: none`, which would replay the enter when the zoom ends.
|
|
155
|
+
*/
|
|
156
|
+
.deck[data-zoom] .slide__surface {
|
|
157
|
+
animation-duration: 0s !important;
|
|
129
158
|
}
|
|
130
159
|
|
|
131
160
|
/* A morph runs on the whole page, so it gets the deck's timing while it plays. */
|