@motionscript/grids 0.0.0-stage → 0.1.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +48 -0
  4. package/README.md +86 -3
  5. package/dist/browser/chunks/chunk-XDUDKWM7.js +2 -0
  6. package/dist/browser/chunks/chunk-XDUDKWM7.js.map +7 -0
  7. package/dist/browser/index.js +2 -0
  8. package/dist/browser/index.js.map +7 -0
  9. package/dist/browser/kit.js +2 -0
  10. package/dist/browser/kit.js.map +7 -0
  11. package/dist/browser/manifest.json +13 -0
  12. package/dist/engine.d.ts +13 -0
  13. package/dist/engine.d.ts.map +1 -0
  14. package/dist/engine.js +13 -0
  15. package/dist/engine.js.map +1 -0
  16. package/dist/index.d.ts +7 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +7 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/kit/index.d.ts +7 -0
  21. package/dist/kit/index.d.ts.map +1 -0
  22. package/dist/kit/index.js +7 -0
  23. package/dist/kit/index.js.map +1 -0
  24. package/dist/kit/item-effects.d.ts +37 -0
  25. package/dist/kit/item-effects.d.ts.map +1 -0
  26. package/dist/kit/item-effects.js +76 -0
  27. package/dist/kit/item-effects.js.map +1 -0
  28. package/dist/kit/item-grid.d.ts +235 -0
  29. package/dist/kit/item-grid.d.ts.map +1 -0
  30. package/dist/kit/item-grid.js +519 -0
  31. package/dist/kit/item-grid.js.map +1 -0
  32. package/dist/kit/order.d.ts +13 -0
  33. package/dist/kit/order.d.ts.map +1 -0
  34. package/dist/kit/order.js +43 -0
  35. package/dist/kit/order.js.map +1 -0
  36. package/dist/kit/random.d.ts +7 -0
  37. package/dist/kit/random.d.ts.map +1 -0
  38. package/dist/kit/random.js +29 -0
  39. package/dist/kit/random.js.map +1 -0
  40. package/dist/kit/slots/index.d.ts +22 -0
  41. package/dist/kit/slots/index.d.ts.map +1 -0
  42. package/dist/kit/slots/index.js +28 -0
  43. package/dist/kit/slots/index.js.map +1 -0
  44. package/dist/kit/slots/path-templates.d.ts +15 -0
  45. package/dist/kit/slots/path-templates.d.ts.map +1 -0
  46. package/dist/kit/slots/path-templates.js +113 -0
  47. package/dist/kit/slots/path-templates.js.map +1 -0
  48. package/dist/kit/slots/path.d.ts +45 -0
  49. package/dist/kit/slots/path.d.ts.map +1 -0
  50. package/dist/kit/slots/path.js +87 -0
  51. package/dist/kit/slots/path.js.map +1 -0
  52. package/dist/kit/slots/radial.d.ts +30 -0
  53. package/dist/kit/slots/radial.d.ts.map +1 -0
  54. package/dist/kit/slots/radial.js +45 -0
  55. package/dist/kit/slots/radial.js.map +1 -0
  56. package/dist/kit/slots/rectangular.d.ts +42 -0
  57. package/dist/kit/slots/rectangular.d.ts.map +1 -0
  58. package/dist/kit/slots/rectangular.js +79 -0
  59. package/dist/kit/slots/rectangular.js.map +1 -0
  60. package/dist/kit/slots/slot.d.ts +62 -0
  61. package/dist/kit/slots/slot.d.ts.map +1 -0
  62. package/dist/kit/slots/slot.js +41 -0
  63. package/dist/kit/slots/slot.js.map +1 -0
  64. package/dist/kit/slots/sphere.d.ts +36 -0
  65. package/dist/kit/slots/sphere.d.ts.map +1 -0
  66. package/dist/kit/slots/sphere.js +68 -0
  67. package/dist/kit/slots/sphere.js.map +1 -0
  68. package/dist/kit/stagger.d.ts +24 -0
  69. package/dist/kit/stagger.d.ts.map +1 -0
  70. package/dist/kit/stagger.js +90 -0
  71. package/dist/kit/stagger.js.map +1 -0
  72. package/dist/nodes.d.ts +15 -0
  73. package/dist/nodes.d.ts.map +1 -0
  74. package/dist/nodes.js +15 -0
  75. package/dist/nodes.js.map +1 -0
  76. package/dist/path-grid/index.d.ts +2 -0
  77. package/dist/path-grid/index.d.ts.map +1 -0
  78. package/dist/path-grid/index.js +2 -0
  79. package/dist/path-grid/index.js.map +1 -0
  80. package/dist/path-grid/path-grid.d.ts +82 -0
  81. package/dist/path-grid/path-grid.d.ts.map +1 -0
  82. package/dist/path-grid/path-grid.js +192 -0
  83. package/dist/path-grid/path-grid.js.map +1 -0
  84. package/dist/radial-grid/index.d.ts +2 -0
  85. package/dist/radial-grid/index.d.ts.map +1 -0
  86. package/dist/radial-grid/index.js +2 -0
  87. package/dist/radial-grid/index.js.map +1 -0
  88. package/dist/radial-grid/radial-grid.d.ts +51 -0
  89. package/dist/radial-grid/radial-grid.d.ts.map +1 -0
  90. package/dist/radial-grid/radial-grid.js +95 -0
  91. package/dist/radial-grid/radial-grid.js.map +1 -0
  92. package/dist/rectangular-grid/index.d.ts +2 -0
  93. package/dist/rectangular-grid/index.d.ts.map +1 -0
  94. package/dist/rectangular-grid/index.js +2 -0
  95. package/dist/rectangular-grid/index.js.map +1 -0
  96. package/dist/rectangular-grid/rectangular-grid.d.ts +79 -0
  97. package/dist/rectangular-grid/rectangular-grid.d.ts.map +1 -0
  98. package/dist/rectangular-grid/rectangular-grid.js +156 -0
  99. package/dist/rectangular-grid/rectangular-grid.js.map +1 -0
  100. package/dist/sphere-grid/index.d.ts +2 -0
  101. package/dist/sphere-grid/index.d.ts.map +1 -0
  102. package/dist/sphere-grid/index.js +2 -0
  103. package/dist/sphere-grid/index.js.map +1 -0
  104. package/dist/sphere-grid/sphere-grid.d.ts +68 -0
  105. package/dist/sphere-grid/sphere-grid.d.ts.map +1 -0
  106. package/dist/sphere-grid/sphere-grid.js +149 -0
  107. package/dist/sphere-grid/sphere-grid.js.map +1 -0
  108. package/package.json +71 -3
  109. package/registry.json +32 -0
  110. package/src/engine.ts +12 -0
  111. package/src/index.ts +6 -0
  112. package/src/kit/index.ts +6 -0
  113. package/src/kit/item-effects.ts +114 -0
  114. package/src/kit/item-grid.ts +585 -0
  115. package/src/kit/order.ts +41 -0
  116. package/src/kit/random.ts +29 -0
  117. package/src/kit/slots/index.ts +39 -0
  118. package/src/kit/slots/path-templates.ts +120 -0
  119. package/src/kit/slots/path.ts +120 -0
  120. package/src/kit/slots/radial.ts +62 -0
  121. package/src/kit/slots/rectangular.ts +108 -0
  122. package/src/kit/slots/slot.ts +82 -0
  123. package/src/kit/slots/sphere.ts +94 -0
  124. package/src/kit/stagger.ts +96 -0
  125. package/src/nodes.ts +15 -0
  126. package/src/path-grid/index.ts +1 -0
  127. package/src/path-grid/path-grid.ts +213 -0
  128. package/src/radial-grid/index.ts +1 -0
  129. package/src/radial-grid/radial-grid.ts +90 -0
  130. package/src/rectangular-grid/index.ts +1 -0
  131. package/src/rectangular-grid/rectangular-grid.ts +160 -0
  132. package/src/sphere-grid/index.ts +1 -0
  133. package/src/sphere-grid/sphere-grid.ts +143 -0
@@ -0,0 +1,114 @@
1
+ import type { Size2D } from "@motionscript/sdk"
2
+
3
+ import type { Slot } from "./slots"
4
+
5
+ /**
6
+ * What an item does on its way in (or out): grow from nothing, fade, fly out of
7
+ * the grid's centre, rise into place, flip up, or spin in.
8
+ */
9
+ export const REVEAL_STYLES = ["scale", "fade", "center", "rise", "flip", "spin"] as const
10
+ export type RevealStyle = (typeof REVEAL_STYLES)[number]
11
+
12
+ /** What a wave does to each item as it passes. */
13
+ export const WAVE_STYLES = ["scale", "lift", "fade", "spin"] as const
14
+ export type WaveStyle = (typeof WAVE_STYLES)[number]
15
+
16
+ /**
17
+ * How far into view each item is, `1` shown and `0` gone — past `1` on the
18
+ * way to it under an overshooting ease. Absent means every item is shown.
19
+ */
20
+ export interface RevealState {
21
+ style: RevealStyle
22
+ values: readonly number[]
23
+ }
24
+
25
+ /** Each item's share of the wave passing through it, `0` at rest and `1` at the crest. */
26
+ export interface WaveState {
27
+ style: WaveStyle
28
+ /** How big the crest is — a share of the item's size, opacity or a turn, by style. */
29
+ amount: number
30
+ values: readonly number[]
31
+ }
32
+
33
+ /** Each item's emphasis: `1` the focus, `-1` pushed back, `0` untouched. */
34
+ export interface FocusState {
35
+ /** How far the others fade, `0..1`. */
36
+ dim: number
37
+ /** How much the focus grows, as a share of its size; the others shrink by half that. */
38
+ grow: number
39
+ values: readonly number[]
40
+ }
41
+
42
+ /** Layer item `i`'s reveal, wave and focus onto its slot, in place. */
43
+ export function applyItemEffects(
44
+ s: Slot,
45
+ i: number,
46
+ size: Size2D,
47
+ reveal: RevealState | null | undefined,
48
+ wave: WaveState | null | undefined,
49
+ focus: FocusState | null | undefined,
50
+ ): void {
51
+ if (reveal) applyReveal(s, reveal.style, reveal.values[i] ?? 1, size)
52
+ if (wave) applyWave(s, wave, wave.values[i] ?? 0, size)
53
+ if (focus) applyFocus(s, focus, focus.values[i] ?? 0)
54
+ }
55
+
56
+ function applyReveal(s: Slot, style: RevealStyle, v: number, size: Size2D): void {
57
+ if (v === 1) return
58
+ const shown = clamp01(v)
59
+ switch (style) {
60
+ case "fade":
61
+ s.opacity *= shown
62
+ break
63
+ case "center":
64
+ s.x *= v
65
+ s.y *= v
66
+ s.depth *= v
67
+ s.opacity *= clamp01(v * 4)
68
+ break
69
+ case "rise":
70
+ s.y -= (1 - v) * Math.max(40, size.height)
71
+ s.opacity *= shown
72
+ break
73
+ case "flip":
74
+ s.rotationX += (1 - v) * 90
75
+ break
76
+ case "spin":
77
+ s.rotation -= (1 - v) * 180
78
+ s.scale *= Math.max(0, v)
79
+ break
80
+ default:
81
+ s.scale *= Math.max(0, v)
82
+ }
83
+ }
84
+
85
+ function applyWave(s: Slot, wave: WaveState, w: number, size: Size2D): void {
86
+ if (w === 0) return
87
+ switch (wave.style) {
88
+ case "lift":
89
+ s.y += wave.amount * w * Math.max(40, size.height)
90
+ break
91
+ case "fade":
92
+ s.opacity *= clamp01(1 - wave.amount * w)
93
+ break
94
+ case "spin":
95
+ s.rotation += wave.amount * w * 360
96
+ break
97
+ default:
98
+ s.scale *= 1 + wave.amount * w
99
+ }
100
+ }
101
+
102
+ function applyFocus(s: Slot, focus: FocusState, f: number): void {
103
+ if (f === 0) return
104
+ if (f > 0) {
105
+ s.scale *= 1 + focus.grow * f
106
+ return
107
+ }
108
+ s.opacity *= clamp01(1 - focus.dim * -f)
109
+ s.scale *= Math.max(0, 1 + focus.grow * 0.5 * f)
110
+ }
111
+
112
+ function clamp01(v: number): number {
113
+ return v < 0 ? 0 : v > 1 ? 1 : v
114
+ }
@@ -0,0 +1,585 @@
1
+ import {
2
+ command,
3
+ easeIn,
4
+ easeInOut,
5
+ easeOut,
6
+ findNode2D,
7
+ flowOps,
8
+ frameIn,
9
+ insetsOps,
10
+ Node2D,
11
+ property,
12
+ sizeInputOps,
13
+ type BoxBounds,
14
+ type ChildPlacement,
15
+ type Command,
16
+ type CommandArgs,
17
+ type MeasureContext2D,
18
+ type EasingFunction,
19
+ type Node2DProps,
20
+ type Size2D,
21
+ type SizeConstraints,
22
+ type SpaceCamera,
23
+ } from "@motionscript/sdk"
24
+
25
+ // Once this kit's own; kept on its barrel for the grids that import them from it.
26
+ export { findNode2D, frameIn } from "@motionscript/sdk"
27
+
28
+ import {
29
+ applyItemEffects,
30
+ REVEAL_STYLES,
31
+ WAVE_STYLES,
32
+ type FocusState,
33
+ type RevealState,
34
+ type RevealStyle,
35
+ type WaveState,
36
+ type WaveStyle,
37
+ } from "./item-effects"
38
+ import { normalizeOrder, parseOrder, slotOfItem } from "./order"
39
+ import { hashSeed, shuffled } from "./random"
40
+ import { blendSlot, focalLengthOf, slotsOf, spreadSlot, type Arrangement, type Slot } from "./slots"
41
+ import { STAGGER_ORDERS, staggerProgress, staggerRanks, type StaggerOrder } from "./stagger"
42
+
43
+ /** An arrangement a grid is leaving, kept as data so its slots can be worked out afresh every frame. */
44
+ export interface GridSnapshot {
45
+ arrangement: Arrangement
46
+ /** The grid the arrangement was borrowed from, or `""` for the grid's own. */
47
+ source: string
48
+ order: readonly number[]
49
+ spread: number
50
+ }
51
+
52
+ /** A change of arrangement under way: where the items are coming from, and how far each has come. */
53
+ export interface GridTransition {
54
+ from: GridSnapshot
55
+ /** Each item's eased, staggered progress, `0..1`. */
56
+ mix: readonly number[]
57
+ }
58
+
59
+ export interface ItemGridProps extends Node2DProps {
60
+ /** Items in slot order: slot `k` holds child `order[k]`. Empty is the children's own order. */
61
+ order: number[]
62
+ /** `0` gathers every item at the centre, `1` is the arrangement, past `1` pushes them apart. */
63
+ spread: number
64
+ /** Another grid whose arrangement this one takes on — what {@link ItemGrid.morphInto} animates. */
65
+ arrangeLike: string
66
+ // What the per-item commands write. State rather than settings: a command
67
+ // owns each while it runs, and a document has no reason to name them.
68
+ revealState: RevealState | null
69
+ waveState: WaveState | null
70
+ focusState: FocusState | null
71
+ transition: GridTransition | null
72
+ }
73
+
74
+ /** What a command that moves the items to new slots is built from. */
75
+ export interface TransitionArgs {
76
+ data?: StaggerArgs
77
+ duration: number
78
+ easing?: EasingFunction
79
+ }
80
+
81
+ /** The stagger options every per-item command takes. */
82
+ export interface StaggerArgs {
83
+ staggerOrder?: StaggerOrder
84
+ /** `0..0.95`: how much of the command's length the items' offsets take up. */
85
+ stagger?: number
86
+ }
87
+
88
+ /** `@command` descriptors for {@link StaggerArgs}, `stagger` defaulting to `fallback`. */
89
+ export function staggerArgs(fallback: number) {
90
+ return [
91
+ { key: "staggerOrder", kind: "enum", options: STAGGER_ORDERS, default: "index" },
92
+ { key: "stagger", min: 0, max: 0.95, step: 0.05, default: fallback },
93
+ ] as const
94
+ }
95
+
96
+ /**
97
+ * Every grid's document key — what a reference to "another grid" may name. A
98
+ * `nodeRef` descriptor carries it as `nodeTypes`, for a host's picker to offer
99
+ * only those.
100
+ */
101
+ export const ITEM_GRID_TYPES = ["rectangularGrid", "radialGrid", "pathGrid", "sphereGrid"] as const
102
+
103
+ /** The stagger a move to new slots takes when its command states none. */
104
+ export const TRANSITION_STAGGER = 0.3
105
+
106
+ const NO_CONTENT: Size2D = { width: 0, height: 0 }
107
+ const CENTRE = { x: 0, y: 0 }
108
+
109
+ /**
110
+ * The base of every grid: a container that places each of its children in a
111
+ * slot of an arrangement — a rectangular grid, a ring, a path, a sphere.
112
+ *
113
+ * ## Children only
114
+ *
115
+ * The items are the children, in layer order. A child's slot is a parent
116
+ * transform on it, so its own `x`/`y`/`rotation`/`scale` — and any command on
117
+ * them — still work, relative to the slot. A group is one item; a grid of
118
+ * grids is a grid of items that happen to be grids.
119
+ *
120
+ * ## One pass
121
+ *
122
+ * Every frame: measure the children, ask the arrangement for its slots (a pure
123
+ * function of the props, the sizes and the clock), blend them from where a
124
+ * transition is leaving, and layer on the per-item effects — reveal, wave,
125
+ * focus. What is left is each child's box and {@link ChildPlacement}, which the
126
+ * renderer, picking and `global` all read, so a selection outline lands on
127
+ * the pixels even on a sphere.
128
+ *
129
+ * ## Commands
130
+ *
131
+ * Every command is a pure function of time, like the rest of the library. The
132
+ * per-item ones stagger *inside* their duration — a one-second reveal takes one
133
+ * second for four children or four hundred — and write their per-item state
134
+ * into props ({@link revealState} and its siblings), which is what keeps them
135
+ * seekable. The ones that change the arrangement itself (a reorder, a morph, a
136
+ * column count) keep the arrangement they are leaving as data and blend each
137
+ * item from its old slot to its new one.
138
+ */
139
+ export abstract class ItemGrid<P extends ItemGridProps = ItemGridProps> extends Node2D<P> {
140
+ @property({ default: [], kind: "order", animatable: false }) declare order: number[]
141
+ @property({ default: 1, min: 0, max: 3, step: 0.05 }) declare spread: number
142
+ @property({ default: "", kind: "nodeRef", nodeTypes: ITEM_GRID_TYPES, animatable: false }) declare arrangeLike: string
143
+ // Unset until a command writes one, so a fresh node's row does not state them.
144
+ @property({ default: undefined, kind: "state", animatable: false }) declare revealState: RevealState | null | undefined
145
+ @property({ default: undefined, kind: "state", animatable: false }) declare waveState: WaveState | null | undefined
146
+ @property({ default: undefined, kind: "state", animatable: false }) declare focusState: FocusState | null | undefined
147
+ @property({ default: undefined, kind: "state", animatable: false }) declare transition: GridTransition | null | undefined
148
+
149
+ /**
150
+ * Bring the items in, one after another. Every item starts hidden, so place
151
+ * it where the grid enters — or after a {@link conceal}.
152
+ */
153
+ @command({ args: [{ key: "style", kind: "enum", options: REVEAL_STYLES, default: "scale" }, ...staggerArgs(0.5)] })
154
+ reveal(args: CommandArgs<{ style?: RevealStyle } & StaggerArgs> & { duration: number }): Command<ItemGridProps> {
155
+ const style = args.data?.style ?? "scale"
156
+ const ease = args.easing ?? (style === "scale" || style === "flip" ? easeOut("back") : easeOut("cubic"))
157
+ const ranks = this._ranks(args.data?.staggerOrder)
158
+ const stagger = args.data?.stagger ?? 0.5
159
+ return this.command<ItemGridProps>((t) => {
160
+ if (t >= 1) return { revealState: null }
161
+ return { revealState: { style, values: ranks.map((rank) => ease(staggerProgress(rank, t, stagger))) } }
162
+ }, args.duration)
163
+ }
164
+
165
+ /** Take the items out, one after another — {@link reveal} in reverse. They stay hidden after. */
166
+ @command({ args: [{ key: "style", kind: "enum", options: REVEAL_STYLES, default: "scale" }, ...staggerArgs(0.5)] })
167
+ conceal(args: CommandArgs<{ style?: RevealStyle } & StaggerArgs> & { duration: number }): Command<ItemGridProps> {
168
+ const style = args.data?.style ?? "scale"
169
+ const ease = args.easing ?? (style === "scale" || style === "flip" ? easeIn("back") : easeInOut("cubic"))
170
+ const ranks = this._ranks(args.data?.staggerOrder)
171
+ const stagger = args.data?.stagger ?? 0.5
172
+ const from = this.revealState?.values
173
+ return this.command<ItemGridProps>((t) => ({
174
+ revealState: {
175
+ style,
176
+ values: ranks.map((rank, i) => (from?.[i] ?? 1) * (1 - ease(staggerProgress(rank, t, stagger)))),
177
+ },
178
+ }), args.duration)
179
+ }
180
+
181
+ /** Send a ripple through the items that swells each in turn and settles back. */
182
+ @command({
183
+ args: [
184
+ { key: "style", kind: "enum", options: WAVE_STYLES, default: "scale" },
185
+ { key: "amount", min: 0, max: 2, step: 0.05, default: 0.3 },
186
+ ...staggerArgs(0.7),
187
+ ],
188
+ })
189
+ wave(args: CommandArgs<{ style?: WaveStyle; amount?: number } & StaggerArgs> & { duration: number }): Command<ItemGridProps> {
190
+ const style = args.data?.style ?? "scale"
191
+ const amount = args.data?.amount ?? 0.3
192
+ const ease = args.easing ?? easeInOut("sine")
193
+ const ranks = this._ranks(args.data?.staggerOrder)
194
+ const stagger = args.data?.stagger ?? 0.7
195
+ return this.command<ItemGridProps>((t) => {
196
+ if (t >= 1) return { waveState: null }
197
+ return {
198
+ waveState: {
199
+ style,
200
+ amount,
201
+ values: ranks.map((rank) => Math.sin(Math.PI * ease(staggerProgress(rank, t, stagger)))),
202
+ },
203
+ }
204
+ }, args.duration)
205
+ }
206
+
207
+ /** Bring one item (by its place in the layers, from 0) forward and push the rest back. */
208
+ @command({
209
+ args: [
210
+ { key: "item", min: 0, step: 1, default: 0 },
211
+ { key: "dim", min: 0, max: 1, step: 0.05, default: 0.7 },
212
+ { key: "grow", min: 0, max: 1, step: 0.05, default: 0.15 },
213
+ ],
214
+ })
215
+ focus(args: CommandArgs<{ item?: number; dim?: number; grow?: number }> & { duration: number }): Command<ItemGridProps> {
216
+ const n = this._itemCount()
217
+ const item = Math.round(args.data?.item ?? 0)
218
+ return this._focusOn((i) => i === item, n, args)
219
+ }
220
+
221
+ /** Bring every item back to even. */
222
+ @command()
223
+ unfocus(args: CommandArgs<Record<string, never>> & { duration: number }): Command<ItemGridProps> {
224
+ const from = this.focusState
225
+ const ease = args.easing ?? easeInOut("cubic")
226
+ return this.command<ItemGridProps>((t) => {
227
+ if (t >= 1 || !from) return { focusState: null }
228
+ const k = 1 - ease(t)
229
+ return { focusState: { ...from, values: from.values.map((v) => v * k) } }
230
+ }, args.duration)
231
+ }
232
+
233
+ /** Gather the items toward the centre (`0`), back to the arrangement (`1`), or push them apart. */
234
+ @command({ args: [{ key: "spread", min: 0, max: 3, step: 0.05, default: 1 }] })
235
+ spreadTo(args: CommandArgs<{ spread: number }> & { duration: number }): Command<P> {
236
+ return this.to({ data: { spread: args.data?.spread ?? 1 } as Partial<P>, duration: args.duration, easing: args.easing })
237
+ }
238
+
239
+ /** Send every item to a random slot. The same `seed` always deals the same way. */
240
+ @command({ args: [{ key: "seed", min: 0, step: 1, default: 1 }, ...staggerArgs(TRANSITION_STAGGER)] })
241
+ shuffle(args: CommandArgs<{ seed?: number } & StaggerArgs> & { duration: number }): Command<P> {
242
+ const current = normalizeOrder(this.order, this._itemCount())
243
+ let next = shuffled(current, args.data?.seed ?? 1)
244
+ // A shuffle that deals everything back where it was would look like nothing happened.
245
+ if (next.length > 1 && next.every((item, k) => item === current[k])) next = [...next.slice(1), next[0]]
246
+ return this._transitionTo({ order: next } as Partial<P>, args)
247
+ }
248
+
249
+ /** Two items trade places, by their place in the layers. */
250
+ @command({
251
+ args: [
252
+ { key: "first", min: 0, step: 1, default: 0 },
253
+ { key: "second", min: 0, step: 1, default: 1 },
254
+ ...staggerArgs(0),
255
+ ],
256
+ })
257
+ swap(args: CommandArgs<{ first?: number; second?: number } & StaggerArgs> & { duration: number }): Command<P> {
258
+ const order = normalizeOrder(this.order, this._itemCount())
259
+ const a = order.indexOf(Math.round(args.data?.first ?? 0))
260
+ const b = order.indexOf(Math.round(args.data?.second ?? 1))
261
+ if (a >= 0 && b >= 0) [order[a], order[b]] = [order[b], order[a]]
262
+ return this._transitionTo({ order } as Partial<P>, args, 0)
263
+ }
264
+
265
+ /** Move the items into a new order — a list of layer indices, first slot first, like `"2, 0, 1"`. */
266
+ @command({ args: [{ key: "order", kind: "text", default: "" }, ...staggerArgs(TRANSITION_STAGGER)] })
267
+ reorder(args: CommandArgs<{ order?: string | number[] } & StaggerArgs> & { duration: number }): Command<P> {
268
+ const order = normalizeOrder(parseOrder(args.data?.order), this._itemCount())
269
+ return this._transitionTo({ order } as Partial<P>, args)
270
+ }
271
+
272
+ /**
273
+ * Move every item into another grid's arrangement — a grid into a ring, a ring
274
+ * onto a sphere — and keep following it. An empty `target` morphs back to
275
+ * this grid's own.
276
+ */
277
+ @command({ args: [{ key: "target", kind: "nodeRef", nodeTypes: ITEM_GRID_TYPES }, ...staggerArgs(TRANSITION_STAGGER)] })
278
+ morphInto(args: CommandArgs<{ target?: string } & StaggerArgs> & { duration: number }): Command<P> {
279
+ return this._transitionTo({ arrangeLike: args.data?.target ?? "" } as Partial<P>, args)
280
+ }
281
+
282
+ /** This grid's own arrangement, for a content box of `box`. */
283
+ protected abstract arrangementFor(box: Size2D): Arrangement
284
+
285
+ // A grid's size is what its items come to. The base's rule — hug only when
286
+ // built with children — would have a grid from a document, whose children
287
+ // arrive after it, fill its parent instead.
288
+ protected override applyDefaultSize(props?: { readonly width?: unknown; readonly height?: unknown }): void {
289
+ if (!props || props.width === undefined) this.applyProp("width", "hug", { tween: sizeInputOps.lerp })
290
+ if (!props || props.height === undefined) this.applyProp("height", "hug", { tween: sizeInputOps.lerp })
291
+ }
292
+
293
+ /** The room each item is measured in — the whole content box, unless an arrangement has cells. */
294
+ protected itemSpace(_count: number, space: Size2D, _sizes?: readonly Size2D[]): Size2D {
295
+ return space
296
+ }
297
+
298
+ override measure(constraints: SizeConstraints, scope: MeasureContext2D): Partial<Size2D> {
299
+ this.lastMeasure = { constraints, measurer: scope }
300
+ const padding = this.effectivePadding()
301
+ const items = this.flowChildren()
302
+ if (items.length === 0) {
303
+ this._measured = null
304
+ return flowOps.size(this, constraints, padding, NO_CONTENT)
305
+ }
306
+ const space = flowOps.contentBox(this, constraints, padding)
307
+ const sizes = this._measureItems(items, space, scope)
308
+ this._measured = { space, sizes }
309
+ return flowOps.size(this, constraints, padding, extentOf(this._targetSlots(items.length, sizes, space).slots, sizes, this._focal))
310
+ }
311
+
312
+ protected override layoutChildren(rect: BoxBounds, scope: MeasureContext2D): void {
313
+ this._placements.clear()
314
+ const items = this.flowChildren()
315
+ if (items.length === 0) {
316
+ this._slots = []
317
+ this._sizes = []
318
+ this._focal = 0
319
+ return
320
+ }
321
+ const inner = insetsOps.insetBounds(rect.width, rect.height, this.effectivePadding())
322
+ const measured = this._measured
323
+ this._measured = null
324
+ const sizes = measured && measured.sizes.length === items.length && sameSize(measured.space, inner)
325
+ ? measured.sizes
326
+ : this._measureItems(items, inner, scope)
327
+ const slots = this._resolve(sizes, inner)
328
+ this._slots = slots
329
+ this._sizes = sizes
330
+ for (let i = 0; i < items.length; i++) {
331
+ const s = slots[i]
332
+ items[i].layout({ x: inner.x + s.x, y: inner.y - s.y, width: sizes[i].width, height: sizes[i].height }, scope)
333
+ if (s.rotation !== 0 || s.scale !== 1 || s.rotationX !== 0 || s.rotationY !== 0 || s.depth !== 0 || s.opacity !== 1) {
334
+ this._placements.set(items[i], {
335
+ rotation: s.rotation,
336
+ scale: s.scale,
337
+ rotationX: s.rotationX,
338
+ rotationY: s.rotationY,
339
+ depth: s.depth,
340
+ opacity: s.opacity,
341
+ })
342
+ }
343
+ }
344
+ }
345
+
346
+ override _placementOf(child: Node2D): ChildPlacement | null {
347
+ return this._placements.get(child) ?? null
348
+ }
349
+
350
+ /**
351
+ * A grid whose arrangement has depth is seen through a camera of its own,
352
+ * which paints its items far to near — unless it already sits in a space (an
353
+ * orbit camera, a 3D stage), where its items join that one and its camera.
354
+ */
355
+ override _spaceCamera(): SpaceCamera | null {
356
+ const focal = this._focal
357
+ if (focal <= 0 || this._inSpace()) return null
358
+ return {
359
+ perspective: focal,
360
+ orbit: 0,
361
+ elevation: 0,
362
+ dolly: 0,
363
+ lookAt: CENTRE,
364
+ zoom: 1,
365
+ heading: 0,
366
+ viewportX: 0,
367
+ viewportY: 0,
368
+ }
369
+ }
370
+
371
+ private readonly _placements = new Map<Node2D, ChildPlacement>()
372
+ /** What {@link measure} found, for {@link layoutChildren} to reuse in the same pass. */
373
+ private _measured: { space: Size2D; sizes: Size2D[] } | null = null
374
+ /** Each item's slot from the last layout — what a command ranks its stagger by. */
375
+ private _slots: Slot[] = []
376
+ /** Each item's size from the last layout. */
377
+ private _sizes: readonly Size2D[] = []
378
+ /** The last layout's camera distance, `0` while flat. */
379
+ private _focal = 0
380
+ private _borrowCache: { id: string; grid: ItemGrid } | null = null
381
+
382
+ /** How many items a command built now will act on. */
383
+ protected _itemCount(): number {
384
+ return this.flowChildren().length
385
+ }
386
+
387
+ /** Each item's slot as it stands, for a command being built — the last layout's where it still fits. */
388
+ protected _itemSlots(): Slot[] {
389
+ const n = this._itemCount()
390
+ if (this._slots.length === n) return this._slots
391
+ const sizes = new Array<Size2D>(n).fill({ width: 100, height: 100 })
392
+ return this._targetSlots(n, sizes, contentBoxOf(this)).slots
393
+ }
394
+
395
+ /** The item sizes the last layout found. */
396
+ protected _itemSizes(): readonly Size2D[] {
397
+ return this._sizes.length === this._itemCount() ? this._sizes : []
398
+ }
399
+
400
+ /** Each item's start in a stagger. A random order is seeded by the node's id, so it never changes between builds. */
401
+ protected _ranks(order: StaggerOrder | undefined): number[] {
402
+ return staggerRanks(order ?? "index", this._itemSlots(), hashSeed(this.id))
403
+ }
404
+
405
+ /**
406
+ * Write `changes` to the arrangement and move each item from its slot in the
407
+ * one being left to its slot in the new one, staggered.
408
+ */
409
+ protected _transitionTo(changes: Partial<P>, args: TransitionArgs, stagger = TRANSITION_STAGGER): Command<P> {
410
+ const from = this._snapshot()
411
+ const ease = args.easing ?? easeInOut("cubic")
412
+ const ranks = this._ranks(args.data?.staggerOrder)
413
+ const spread = args.data?.stagger ?? stagger
414
+ return this.command<P>((t) => {
415
+ if (t >= 1) return { ...changes, transition: null } as Partial<P>
416
+ const mix = ranks.map((rank) => ease(staggerProgress(rank, t, spread)))
417
+ return { ...changes, transition: { from, mix } } as Partial<P>
418
+ }, args.duration)
419
+ }
420
+
421
+ /** Emphasise the items `isFocus` picks and push the rest back. */
422
+ protected _focusOn(
423
+ isFocus: (item: number) => boolean,
424
+ count: number,
425
+ args: CommandArgs<{ dim?: number; grow?: number }> & { duration: number },
426
+ ): Command<ItemGridProps> {
427
+ const from = this.focusState
428
+ const dim = args.data?.dim ?? 0.7
429
+ const grow = args.data?.grow ?? 0.15
430
+ const target = Array.from({ length: count }, (_, i) => (isFocus(i) ? 1 : -1))
431
+ const ease = args.easing ?? easeInOut("cubic")
432
+ return this.command<ItemGridProps>((t) => {
433
+ const e = ease(t)
434
+ return {
435
+ focusState: {
436
+ dim: lerp(from?.dim ?? dim, dim, e),
437
+ grow: lerp(from?.grow ?? grow, grow, e),
438
+ values: target.map((v, i) => lerp(from?.values[i] ?? 0, v, e)),
439
+ },
440
+ }
441
+ }, args.duration)
442
+ }
443
+
444
+ private _snapshot(): GridSnapshot {
445
+ const source = this._borrowed()
446
+ return {
447
+ arrangement: source ? source.arrangementFor(contentBoxOf(source)) : this.arrangementFor(contentBoxOf(this)),
448
+ source: source?.id ?? "",
449
+ order: normalizeOrder(this.order, this._itemCount()),
450
+ spread: this.spread,
451
+ }
452
+ }
453
+
454
+ private _measureItems(items: readonly Node2D[], space: Size2D, scope: MeasureContext2D): Size2D[] {
455
+ const measured = flowOps.measure({}, items, space, scope).sizes
456
+ const cell = this.itemSpace(items.length, space, measured)
457
+ if (cell === space) return [...measured]
458
+ return [...flowOps.measure({}, items, cell, scope).sizes]
459
+ }
460
+
461
+ /** Where each item sits in the arrangement it is heading for, before any transition or effect. */
462
+ private _targetSlots(count: number, sizes: readonly Size2D[], box: Size2D): { slots: Slot[]; arrangement: Arrangement } {
463
+ const source = this._borrowed()
464
+ const arrangement = source ? source.arrangementFor(contentBoxOf(source)) : this.arrangementFor(box)
465
+ let slots = slotsOf(arrangement, { count, sizes, box: source ? contentBoxOf(source) : box, time: this.time.total })
466
+ if (source) slots = this._fromGrid(source, slots)
467
+ const slotOf = slotOfItem(normalizeOrder(this.order, count))
468
+ return { slots: slotOf.map((k) => spreadSlot(slots[k], this.spread)), arrangement }
469
+ }
470
+
471
+ /** Each item's final slot this frame: the target, blended from any transition, with its effects on. */
472
+ private _resolve(sizes: readonly Size2D[], box: Size2D): Slot[] {
473
+ const count = sizes.length
474
+ const target = this._targetSlots(count, sizes, box)
475
+ const slots = target.slots
476
+ let focal = focalLengthOf(target.arrangement)
477
+ const transition = this.transition
478
+ if (transition) {
479
+ const { from } = transition
480
+ const source = from.source ? this._gridById(from.source) : null
481
+ const fromBox = source ? contentBoxOf(source) : box
482
+ let leaving = slotsOf(from.arrangement, { count, sizes, box: fromBox, time: this.time.total })
483
+ if (source && source !== this) leaving = this._fromGrid(source, leaving)
484
+ const slotOf = slotOfItem(normalizeOrder(from.order, count))
485
+ for (let i = 0; i < count; i++) {
486
+ slots[i] = blendSlot(spreadSlot(leaving[slotOf[i]], from.spread), slots[i], transition.mix[i] ?? 1)
487
+ }
488
+ // A morph into or out of depth keeps the camera for its whole length.
489
+ focal = focal || focalLengthOf(from.arrangement)
490
+ }
491
+ this._focal = focal
492
+ const reveal = this.revealState
493
+ const wave = this.waveState
494
+ const focus = this.focusState
495
+ if (reveal || wave || focus) {
496
+ for (let i = 0; i < count; i++) applyItemEffects(slots[i], i, sizes[i], reveal, wave, focus)
497
+ }
498
+ return slots
499
+ }
500
+
501
+ /** Whether an ancestor places this grid in a space, as `spaceFrameOf` walks it. */
502
+ private _inSpace(): boolean {
503
+ for (let n = this.parent; n; n = n.parent) {
504
+ if (n._spaceCamera()) return true
505
+ if (!n._rendersChildrenInSpace()) return false
506
+ }
507
+ return false
508
+ }
509
+
510
+ /** The grid {@link arrangeLike} names, when it names another grid in this tree. */
511
+ private _borrowed(): ItemGrid | null {
512
+ const id = this.arrangeLike
513
+ if (!id || id === this.id) return null
514
+ return this._gridById(id)
515
+ }
516
+
517
+ private _gridById(id: string): ItemGrid | null {
518
+ if (id === this.id) return this
519
+ const cached = this._borrowCache
520
+ if (cached && cached.id === id && cached.grid.mounted) return cached.grid
521
+ const found = findNode2D(this, id)
522
+ const grid = found instanceof ItemGrid ? found : null
523
+ this._borrowCache = grid ? { id, grid } : null
524
+ return grid
525
+ }
526
+
527
+ /** `slots`, laid out in `source`'s frame, moved into this grid's. */
528
+ private _fromGrid(source: ItemGrid, slots: Slot[]): Slot[] {
529
+ const frame = frameIn(this, source)
530
+ return slots.map((s) => {
531
+ const p = rotateClockwise(s.x * frame.scale, s.y * frame.scale, frame.rotation)
532
+ return {
533
+ ...s,
534
+ x: frame.x + p.x,
535
+ y: frame.y + p.y,
536
+ depth: s.depth * frame.scale,
537
+ rotation: s.rotation + frame.rotation,
538
+ scale: s.scale * frame.scale,
539
+ }
540
+ })
541
+ }
542
+ }
543
+
544
+ /** A node's content box: its laid-out size inside its padding. */
545
+ export function contentBoxOf(grid: Node2D): Size2D {
546
+ const b = grid.layoutBounds
547
+ const inner = insetsOps.insetBounds(b.width, b.height, grid.effectivePadding())
548
+ return { width: inner.width, height: inner.height }
549
+ }
550
+
551
+ /** The box, centred on the grid, that holds every item where its slot puts it. */
552
+ function extentOf(slots: readonly Slot[], sizes: readonly Size2D[], focal: number): Size2D {
553
+ let halfWidth = 0
554
+ let halfHeight = 0
555
+ for (let i = 0; i < slots.length; i++) {
556
+ const s = slots[i]
557
+ const size = sizes[i]
558
+ const rad = (s.rotation * Math.PI) / 180
559
+ const cos = Math.abs(Math.cos(rad))
560
+ const sin = Math.abs(Math.sin(rad))
561
+ // What the camera does to a slot at depth, so a sphere hugs its front.
562
+ const lens = focal > 0 && focal - s.depth > 1 ? focal / (focal - s.depth) : 1
563
+ const w = ((size.width * cos + size.height * sin) / 2) * s.scale * lens
564
+ const h = ((size.width * sin + size.height * cos) / 2) * s.scale * lens
565
+ halfWidth = Math.max(halfWidth, Math.abs(s.x) * lens + w)
566
+ halfHeight = Math.max(halfHeight, Math.abs(s.y) * lens + h)
567
+ }
568
+ return { width: halfWidth * 2, height: halfHeight * 2 }
569
+ }
570
+
571
+ function rotateClockwise(x: number, y: number, degrees: number): { x: number; y: number } {
572
+ if (degrees === 0) return { x, y }
573
+ const rad = (degrees * Math.PI) / 180
574
+ const cos = Math.cos(rad)
575
+ const sin = Math.sin(rad)
576
+ return { x: x * cos + y * sin, y: -x * sin + y * cos }
577
+ }
578
+
579
+ function sameSize(a: Size2D, b: Size2D): boolean {
580
+ return Math.abs(a.width - b.width) < 1e-6 && Math.abs(a.height - b.height) < 1e-6
581
+ }
582
+
583
+ function lerp(a: number, b: number, t: number): number {
584
+ return a + (b - a) * t
585
+ }