@motionscript/grids 0.1.0-alpha.5 → 0.1.0-alpha.7

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 (50) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +47 -15
  3. package/dist/browser/chunks/chunk-J62D7EFZ.js +2 -0
  4. package/dist/browser/chunks/chunk-J62D7EFZ.js.map +7 -0
  5. package/dist/browser/index.js +1 -1
  6. package/dist/browser/index.js.map +3 -3
  7. package/dist/browser/kit.js +1 -1
  8. package/dist/browser/manifest.json +1 -1
  9. package/dist/engine.d.ts +4 -4
  10. package/dist/engine.js +4 -4
  11. package/dist/engine.js.map +1 -1
  12. package/dist/kit/index.d.ts +3 -1
  13. package/dist/kit/index.d.ts.map +1 -1
  14. package/dist/kit/index.js +4 -1
  15. package/dist/kit/index.js.map +1 -1
  16. package/dist/kit/item-grid.d.ts +203 -198
  17. package/dist/kit/item-grid.d.ts.map +1 -1
  18. package/dist/kit/item-grid.js +413 -440
  19. package/dist/kit/item-grid.js.map +1 -1
  20. package/dist/nodes.d.ts +8 -3
  21. package/dist/nodes.d.ts.map +1 -1
  22. package/dist/nodes.js +6 -10
  23. package/dist/nodes.js.map +1 -1
  24. package/dist/path-grid/path-grid.d.ts +187 -73
  25. package/dist/path-grid/path-grid.d.ts.map +1 -1
  26. package/dist/path-grid/path-grid.js +129 -172
  27. package/dist/path-grid/path-grid.js.map +1 -1
  28. package/dist/radial-grid/radial-grid.d.ts +171 -45
  29. package/dist/radial-grid/radial-grid.d.ts.map +1 -1
  30. package/dist/radial-grid/radial-grid.js +55 -87
  31. package/dist/radial-grid/radial-grid.js.map +1 -1
  32. package/dist/rectangular-grid/rectangular-grid.d.ts +203 -70
  33. package/dist/rectangular-grid/rectangular-grid.d.ts.map +1 -1
  34. package/dist/rectangular-grid/rectangular-grid.js +107 -145
  35. package/dist/rectangular-grid/rectangular-grid.js.map +1 -1
  36. package/dist/sphere-grid/sphere-grid.d.ts +192 -60
  37. package/dist/sphere-grid/sphere-grid.d.ts.map +1 -1
  38. package/dist/sphere-grid/sphere-grid.js +90 -137
  39. package/dist/sphere-grid/sphere-grid.js.map +1 -1
  40. package/package.json +4 -4
  41. package/src/engine.ts +4 -4
  42. package/src/kit/index.ts +13 -1
  43. package/src/kit/item-grid.ts +505 -442
  44. package/src/nodes.ts +18 -3
  45. package/src/path-grid/path-grid.ts +137 -164
  46. package/src/radial-grid/radial-grid.ts +69 -78
  47. package/src/rectangular-grid/rectangular-grid.ts +118 -133
  48. package/src/sphere-grid/sphere-grid.ts +103 -117
  49. package/dist/browser/chunks/chunk-XDUDKWM7.js +0 -2
  50. package/dist/browser/chunks/chunk-XDUDKWM7.js.map +0 -7
@@ -1,45 +1,75 @@
1
1
  import {
2
- command,
2
+ commandsFor,
3
3
  easeIn,
4
4
  easeInOut,
5
5
  easeOut,
6
- findNode2D,
7
6
  flowOps,
8
- frameIn,
9
7
  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,
8
+ Types,
9
+ type ChildView,
10
+ type InsetsResolved,
11
+ type LayoutBox,
12
+ type LayoutItem,
13
+ type Measurer,
14
+ type Node2D,
15
+ type NodeFrame,
16
+ type NodeView,
17
+ type Piece,
18
+ type Placement2D,
19
+ type RenderContext,
20
20
  type Size2D,
21
- type SizeConstraints,
22
21
  type SpaceCamera,
22
+ type Timeline,
23
+ type Values,
23
24
  } from "@motionscript/sdk"
24
25
 
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
26
  import {
29
27
  applyItemEffects,
30
28
  REVEAL_STYLES,
31
29
  WAVE_STYLES,
32
30
  type FocusState,
33
31
  type RevealState,
34
- type RevealStyle,
35
32
  type WaveState,
36
- type WaveStyle,
37
33
  } from "./item-effects"
38
34
  import { normalizeOrder, parseOrder, slotOfItem } from "./order"
39
35
  import { hashSeed, shuffled } from "./random"
40
36
  import { blendSlot, focalLengthOf, slotsOf, spreadSlot, type Arrangement, type Slot } from "./slots"
41
37
  import { STAGGER_ORDERS, staggerProgress, staggerRanks, type StaggerOrder } from "./stagger"
42
38
 
39
+ /**
40
+ * What every grid is built from: a container that places each of its children
41
+ * in a slot of an arrangement — a rectangular grid, a ring, a path, a sphere.
42
+ * Spread `state` and `commands` in, take `defaults`, hand `fields` the
43
+ * {@link Arranger} that makes this grid's arrangement from its state, and pass
44
+ * the finished component and its arranger through {@link define}.
45
+ *
46
+ * ## Children only
47
+ *
48
+ * The items are the children, in layer order. A child's slot is a parent
49
+ * transform on it, so its own `x`/`y`/`rotation`/`scale` — and any command on
50
+ * them — still work, relative to the slot. A group is one item; a grid of
51
+ * grids is a grid of items that happen to be grids.
52
+ *
53
+ * ## One pass
54
+ *
55
+ * Every frame: measure the children, ask the arrangement for its slots (a pure
56
+ * function of the state, the sizes and the clock), blend them from where a
57
+ * transition is leaving, and layer on the per-item effects — reveal, wave,
58
+ * focus. What is left is each child's box and slot, which the renderer,
59
+ * picking and `global` all read, so a selection outline lands on the pixels
60
+ * even on a sphere.
61
+ *
62
+ * ## Commands
63
+ *
64
+ * Every command is a pure function of time. The per-item ones stagger *inside*
65
+ * their duration — a one-second reveal takes one second for four children or
66
+ * four hundred — and write their per-item state into entries
67
+ * (`revealState` and its siblings), which is what keeps them seekable. The ones
68
+ * that change the arrangement itself (a reorder, a morph, a column count) keep
69
+ * the arrangement they are leaving as data and blend each item from its old
70
+ * slot to its new one.
71
+ */
72
+
43
73
  /** An arrangement a grid is leaving, kept as data so its slots can be worked out afresh every frame. */
44
74
  export interface GridSnapshot {
45
75
  arrangement: Arrangement
@@ -56,28 +86,6 @@ export interface GridTransition {
56
86
  mix: readonly number[]
57
87
  }
58
88
 
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
89
  /** The stagger options every per-item command takes. */
82
90
  export interface StaggerArgs {
83
91
  staggerOrder?: StaggerOrder
@@ -85,14 +93,6 @@ export interface StaggerArgs {
85
93
  stagger?: number
86
94
  }
87
95
 
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
96
  /**
97
97
  * Every grid's document key — what a reference to "another grid" may name. A
98
98
  * `nodeRef` descriptor carries it as `nodeTypes`, for a host's picker to offer
@@ -103,448 +103,511 @@ export const ITEM_GRID_TYPES = ["rectangularGrid", "radialGrid", "pathGrid", "sp
103
103
  /** The stagger a move to new slots takes when its command states none. */
104
104
  export const TRANSITION_STAGGER = 0.3
105
105
 
106
+ /** A reference to another node, a host's picker offering only `types`. Its fields read a view of it. */
107
+ export function nodeRef(types: readonly string[]) {
108
+ const described = { kind: "nodeRef", nodeTypes: types, animatable: false }
109
+ return Types.custom<string, NodeView | null>("", described)
110
+ }
111
+
112
+ /** The stagger args every per-item command takes, `stagger` defaulting to `fallback`. */
113
+ export function staggerArgs(fallback: number) {
114
+ return {
115
+ staggerOrder: Types.oneOf(STAGGER_ORDERS, { default: "index" }),
116
+ stagger: Types.number(fallback, { min: 0, max: 0.95, step: 0.05 }),
117
+ }
118
+ }
119
+
120
+ /**
121
+ * A command arg a host fills in with `hint`, which a row may leave out: the
122
+ * command then keeps what the grid has. A run arg's default is both what a
123
+ * host offers and what an omitted arg means, so the hint rides on the arg's
124
+ * description alone, and an omitted arg reads `undefined`.
125
+ */
126
+ export function hinted<T extends number | string>(hint: T, options: object = {}) {
127
+ const described: Record<string, unknown> = { ...options, default: hint }
128
+ return Types.custom<T | undefined>(undefined, described as never)
129
+ }
130
+
131
+ /** A command arg naming another node, a host's picker offering only `types`; left out, it reads `undefined`. */
132
+ export function nodeRefArg(types: readonly string[]) {
133
+ const described = { kind: "nodeRef", nodeTypes: types }
134
+ return Types.custom<string | undefined>(undefined, described)
135
+ }
136
+
137
+ /** The state every grid shares. */
138
+ export const state = {
139
+ /** Items in slot order: slot `k` holds child `order[k]`. Empty is the children's own order. */
140
+ order: Types.custom<number[]>([], { kind: "order", animatable: false }),
141
+ /** `0` gathers every item at the centre, `1` is the arrangement, past `1` pushes them apart. */
142
+ spread: Types.number(1, { min: 0, max: 3, step: 0.05 }),
143
+ /** Another grid whose arrangement this one takes on — what `morphInto` animates. */
144
+ arrangeLike: nodeRef(ITEM_GRID_TYPES),
145
+ // What the per-item commands write. State rather than settings: a command
146
+ // owns each while it runs, and a document has no reason to name them. Unset
147
+ // until a command writes one, so a fresh node's row does not state them.
148
+ revealState: Types.custom<RevealState | null | undefined>(undefined, { kind: "state", animatable: false }),
149
+ waveState: Types.custom<WaveState | null | undefined>(undefined, { kind: "state", animatable: false }),
150
+ focusState: Types.custom<FocusState | null | undefined>(undefined, { kind: "state", animatable: false }),
151
+ transition: Types.custom<GridTransition | null | undefined>(undefined, { kind: "state", animatable: false }),
152
+ /** The grid a transition's leaving arrangement was borrowed from, followed while the items travel. */
153
+ transitionSource: Types.custom<string, NodeView | null, { readonly private: true }>("", { kind: "nodeRef", private: true }),
154
+ }
155
+
156
+ /** A grid's size is what its items come to, whether it was built with children or they arrive after it. */
157
+ export const defaults = { width: "hug", height: "hug" } as const
158
+
159
+ /** What a grid's commands read of its state, beyond its own arrangement's entries. */
160
+ export type GridValues = Values<typeof state> & { readonly padding: InsetsResolved }
161
+
162
+ /** What a grid's measure, layout and render read of its state, beyond its own arrangement's entries. */
163
+ export type GridState = GridValues & {
164
+ readonly width: LayoutItem["width"]
165
+ readonly height: LayoutItem["height"]
166
+ readonly children: readonly ChildView[]
167
+ }
168
+
169
+ /** What makes one grid's arrangement from its state: the rest of a grid is this module. */
170
+ export interface Arranger<S = unknown> {
171
+ /** This grid's own arrangement, for a content box of `box`. */
172
+ arrangement(state: S, box: Size2D): Arrangement
173
+ /** The room each item is measured in — the whole content box, unless the arrangement has cells. */
174
+ itemSpace?(state: S, count: number, space: Size2D): Size2D
175
+ }
176
+
177
+ type GridComponent = { readonly key: string; readonly node: abstract new (...args: never[]) => object }
178
+
179
+ const GRIDS = new Map<string, { readonly component: GridComponent; readonly arranger: Arranger<never> }>()
180
+
181
+ /**
182
+ * `component` as a grid: registers its `arranger`, which another grid borrowing
183
+ * its arrangement or morphing into it reads it by, and which {@link isGrid} knows it by.
184
+ */
185
+ export function define<C extends GridComponent, S>(component: C, arranger: Arranger<S>): C {
186
+ GRIDS.set(component.key, { component, arranger: arranger as Arranger<never> })
187
+ return component
188
+ }
189
+
190
+ /** Whether `value` is a node of one of the grids {@link define} has seen. */
191
+ export function isGrid(value: unknown): boolean {
192
+ for (const { component } of GRIDS.values()) if (value instanceof component.node) return true
193
+ return false
194
+ }
195
+
196
+ /** The grid's measure, layout and render, for `arranger`'s arrangement: spread them into the component. */
197
+ export function fields<S>(arranger: Arranger<S>) {
198
+ return {
199
+ measure: (measurer: Measurer, s: GridState & S, children: readonly ChildView[]) => measure(measurer, s, children, arranger),
200
+ layout: (box: LayoutBox, s: GridState & S, children: readonly ChildView[]) => layout(box, s, children, arranger),
201
+ render: (ctx: RenderContext, s: GridState & S) => render(ctx, s, arranger),
202
+ }
203
+ }
204
+
106
205
  const NO_CONTENT: Size2D = { width: 0, height: 0 }
107
206
  const CENTRE = { x: 0, y: 0 }
108
207
 
208
+ /** What `measure` found, for `layout` to reuse in the same pass. Keyed by the grid's state, one object per node. */
209
+ const measured = new WeakMap<object, { space: Size2D; sizes: Size2D[] }>()
210
+
211
+ export function measure<S>(measurer: Measurer, s: GridState & S, children: readonly ChildView[], arranger: Arranger<S>): Size2D {
212
+ const constraints = { maxWidth: measurer.max.width, maxHeight: measurer.max.height }
213
+ const padding = s.padding
214
+ if (children.length === 0) {
215
+ measured.delete(s)
216
+ return flowOps.size(s, constraints, padding, NO_CONTENT)
217
+ }
218
+ const space = flowOps.contentBox(s, constraints, padding)
219
+ const sizes = measureItems(children, space, s, arranger)
220
+ measured.set(s, { space, sizes })
221
+ const grid = { state: s, arranger, children, isSelf: sharesChildren(children) }
222
+ const target = targetSlots(grid, sizes.length, sizes, space, measurer.time.total)
223
+ return flowOps.size(s, constraints, padding, extentOf(target.slots, sizes, focalOf(s, target.arrangement)))
224
+ }
225
+
226
+ export function layout<S>(box: LayoutBox, s: GridState & S, children: readonly ChildView[], arranger: Arranger<S>): readonly Placement2D[] {
227
+ const kept = measured.get(s)
228
+ measured.delete(s)
229
+ if (children.length === 0) return []
230
+ const inner = insetsOps.insetBounds(box.width, box.height, s.padding)
231
+ const sizes = kept && kept.sizes.length === children.length && sameSize(kept.space, inner)
232
+ ? kept.sizes
233
+ : measureItems(children, inner, s, arranger)
234
+ const slots = resolve({ state: s, arranger, children, isSelf: sharesChildren(children) }, sizes, inner, box.time.total)
235
+ return slots.map((slot, i) => placement(inner, slot, sizes[i]))
236
+ }
237
+
109
238
  /**
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.
239
+ * A grid whose arrangement has depth is seen through a camera of its own,
240
+ * which paints its items far to near — unless it already sits in a space (an
241
+ * orbit camera, a 3D stage), where its items join that one and its camera.
138
242
  */
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
243
+ export function render<S>(ctx: RenderContext, s: GridState & S, arranger: Arranger<S>): void {
244
+ // With no items there is nothing to see through a camera.
245
+ const focal = s.children.length === 0 ? 0 : focalOf(s, arrangementOf({ state: s, arranger, children: [], isSelf: () => false }, contentBox(ctx.size, s.padding)))
246
+ if (focal > 0 && !ctx.inSpace) ctx.space(cameraAt(focal))
247
+ else ctx.children()
248
+ }
148
249
 
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
- }
250
+ /** A node's content box: its laid-out size inside its padding. */
251
+ export function contentBoxOf(grid: Node2D): Size2D {
252
+ const b = grid.layoutBounds
253
+ return contentBox({ width: b.width, height: b.height }, grid.effectivePadding())
254
+ }
255
+
256
+ // ---- what commands share --------------------------------------------------
164
257
 
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)))),
258
+ type GridTimeline = Timeline<StaggerArgs, never>
259
+
260
+ /**
261
+ * Each item's slot in its arrangement as it stands, for a command being built,
262
+ * at the clock commands are built against (the tree stands at time 0 while a
263
+ * timeline compiles): what its stagger is ranked by.
264
+ */
265
+ export function itemSlots(timeline: Timeline<object, never>, s: GridValues, children: readonly ChildView[]): Slot[] {
266
+ const grid = commandGrid(timeline, s, children)
267
+ const sizes = children.map((child) => child.size)
268
+ return targetSlots(grid, children.length, sizes, contentBox(timeline.node.size, s.padding), 0).slots
269
+ }
270
+
271
+ /** Each item's start in a stagger. A random order is seeded by the node's id, so it never changes between builds. */
272
+ export function ranks(timeline: Timeline<object, never>, s: GridValues, children: readonly ChildView[], order: StaggerOrder | undefined): number[] {
273
+ return staggerRanks(order ?? "index", itemSlots(timeline, s, children), hashSeed(timeline.node.id))
274
+ }
275
+
276
+ /**
277
+ * Write `changes` to the arrangement and move each item from its slot in the
278
+ * one being left to its slot in the new one, staggered.
279
+ */
280
+ export function transitionTo(
281
+ timeline: GridTimeline,
282
+ s: GridValues,
283
+ children: readonly ChildView[],
284
+ changes: Record<string, unknown>,
285
+ stagger = TRANSITION_STAGGER,
286
+ ): Piece {
287
+ const from = snapshot(timeline, s, children)
288
+ const ease = timeline.easing ?? easeInOut("cubic")
289
+ const order = ranks(timeline, s, children, timeline.args.staggerOrder)
290
+ const spread = timeline.args.stagger ?? stagger
291
+ return timeline.animate((t) => {
292
+ if (t >= 1) return { ...changes, transition: null, transitionSource: "" } as never
293
+ const mix = order.map((rank) => ease(staggerProgress(rank, t, spread)))
294
+ return { ...changes, transition: { from, mix }, transitionSource: from.source } as never
295
+ }, { eased: false })
296
+ }
297
+
298
+ /** Emphasise the items `isFocus` picks and push the rest back. */
299
+ export function focusOn(
300
+ timeline: Timeline<object, never>,
301
+ s: GridValues,
302
+ isFocus: (item: number) => boolean,
303
+ count: number,
304
+ dim: number,
305
+ grow: number,
306
+ ): Piece {
307
+ const from = s.focusState
308
+ const target = Array.from({ length: count }, (_, i) => (isFocus(i) ? 1 : -1))
309
+ const ease = timeline.easing ?? easeInOut("cubic")
310
+ return timeline.animate((t) => {
311
+ const e = ease(t)
312
+ return {
313
+ focusState: {
314
+ dim: lerp(from?.dim ?? dim, dim, e),
315
+ grow: lerp(from?.grow ?? grow, grow, e),
316
+ values: target.map((v, i) => lerp(from?.values[i] ?? 0, v, e)),
177
317
  },
178
- }), args.duration)
179
- }
318
+ } as never
319
+ }, { eased: false })
320
+ }
180
321
 
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: {
322
+ // What the commands are typed against: the shared state, and the padding every node has.
323
+ const GRID_STATE = { ...state, padding: Types.insets }
324
+
325
+ export const commands = commandsFor(GRID_STATE, {
326
+ /**
327
+ * Bring the items in, one after another. Every item starts hidden, so place
328
+ * it where the grid enters — or after a `conceal`.
329
+ */
330
+ reveal: {
331
+ args: { style: Types.oneOf(REVEAL_STYLES, { default: "scale" }), ...staggerArgs(0.5) },
332
+ run: (timeline, s, children) => {
333
+ const style = timeline.args.style ?? "scale"
334
+ const ease = timeline.easing ?? (style === "scale" || style === "flip" ? easeOut("back") : easeOut("cubic"))
335
+ const order = ranks(timeline as never, s, children, timeline.args.staggerOrder)
336
+ const stagger = timeline.args.stagger ?? 0.5
337
+ return timeline.animate((t) => {
338
+ if (t >= 1) return { revealState: null }
339
+ return { revealState: { style, values: order.map((rank) => ease(staggerProgress(rank, t, stagger))) } }
340
+ }, { eased: false })
341
+ },
342
+ },
343
+ /** Take the items out, one after another — `reveal` in reverse. They stay hidden after. */
344
+ conceal: {
345
+ args: { style: Types.oneOf(REVEAL_STYLES, { default: "scale" }), ...staggerArgs(0.5) },
346
+ run: (timeline, s, children) => {
347
+ const style = timeline.args.style ?? "scale"
348
+ const ease = timeline.easing ?? (style === "scale" || style === "flip" ? easeIn("back") : easeInOut("cubic"))
349
+ const order = ranks(timeline as never, s, children, timeline.args.staggerOrder)
350
+ const stagger = timeline.args.stagger ?? 0.5
351
+ const from = s.revealState?.values
352
+ return timeline.animate((t) => ({
353
+ revealState: {
199
354
  style,
200
- amount,
201
- values: ranks.map((rank) => Math.sin(Math.PI * ease(staggerProgress(rank, t, stagger)))),
355
+ values: order.map((rank, i) => (from?.[i] ?? 1) * (1 - ease(staggerProgress(rank, t, stagger)))),
202
356
  },
203
- }
204
- }, args.duration)
205
- }
206
-
357
+ }), { eased: false })
358
+ },
359
+ },
360
+ /** Send a ripple through the items that swells each in turn and settles back. */
361
+ wave: {
362
+ args: {
363
+ style: Types.oneOf(WAVE_STYLES, { default: "scale" }),
364
+ amount: Types.number(0.3, { min: 0, max: 2, step: 0.05 }),
365
+ ...staggerArgs(0.7),
366
+ },
367
+ run: (timeline, s, children) => {
368
+ const style = timeline.args.style ?? "scale"
369
+ const amount = timeline.args.amount ?? 0.3
370
+ const ease = timeline.easing ?? easeInOut("sine")
371
+ const order = ranks(timeline as never, s, children, timeline.args.staggerOrder)
372
+ const stagger = timeline.args.stagger ?? 0.7
373
+ return timeline.animate((t) => {
374
+ if (t >= 1) return { waveState: null }
375
+ return { waveState: { style, amount, values: order.map((rank) => Math.sin(Math.PI * ease(staggerProgress(rank, t, stagger)))) } }
376
+ }, { eased: false })
377
+ },
378
+ },
207
379
  /** 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
-
380
+ focus: {
381
+ args: {
382
+ item: Types.number(0, { min: 0, step: 1 }),
383
+ dim: Types.number(0.7, { min: 0, max: 1, step: 0.05 }),
384
+ grow: Types.number(0.15, { min: 0, max: 1, step: 0.05 }),
385
+ },
386
+ run: (timeline, s, children) => {
387
+ const item = Math.round(timeline.args.item ?? 0)
388
+ return focusOn(timeline as never, s, (i) => i === item, children.length, timeline.args.dim ?? 0.7, timeline.args.grow ?? 0.15)
389
+ },
390
+ },
221
391
  /** 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) => {
392
+ unfocus: (timeline, s) => {
393
+ const from = s.focusState
394
+ const ease = timeline.easing ?? easeInOut("cubic")
395
+ return timeline.animate((t) => {
227
396
  if (t >= 1 || !from) return { focusState: null }
228
397
  const k = 1 - ease(t)
229
398
  return { focusState: { ...from, values: from.values.map((v) => v * k) } }
230
- }, args.duration)
231
- }
232
-
399
+ }, { eased: false })
400
+ },
233
401
  /** 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
-
402
+ spreadTo: {
403
+ args: { spread: Types.number(1, { min: 0, max: 3, step: 0.05 }) },
404
+ run: (timeline) => timeline.tween({ spread: timeline.args.spread ?? 1 }),
405
+ },
239
406
  /** 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
-
407
+ shuffle: {
408
+ args: { seed: Types.number(1, { min: 0, step: 1 }), ...staggerArgs(TRANSITION_STAGGER) },
409
+ run: (timeline, s, children) => {
410
+ const current = normalizeOrder(s.order, children.length)
411
+ let next = shuffled(current, timeline.args.seed ?? 1)
412
+ // A shuffle that deals everything back where it was would look like nothing happened.
413
+ if (next.length > 1 && next.every((item, k) => item === current[k])) next = [...next.slice(1), next[0]]
414
+ return transitionTo(timeline as never, s, children, { order: next })
415
+ },
416
+ },
249
417
  /** 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 },
418
+ swap: {
419
+ args: {
420
+ first: Types.number(0, { min: 0, step: 1 }),
421
+ second: Types.number(1, { min: 0, step: 1 }),
254
422
  ...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
-
423
+ },
424
+ run: (timeline, s, children) => {
425
+ const order = normalizeOrder(s.order, children.length)
426
+ const a = order.indexOf(Math.round(timeline.args.first ?? 0))
427
+ const b = order.indexOf(Math.round(timeline.args.second ?? 1))
428
+ if (a >= 0 && b >= 0) [order[a], order[b]] = [order[b], order[a]]
429
+ return transitionTo(timeline as never, s, children, { order }, 0)
430
+ },
431
+ },
265
432
  /** 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
-
433
+ reorder: {
434
+ args: { order: Types.text(""), ...staggerArgs(TRANSITION_STAGGER) },
435
+ run: (timeline, s, children) => {
436
+ const order = normalizeOrder(parseOrder(timeline.args.order), children.length)
437
+ return transitionTo(timeline as never, s, children, { order })
438
+ },
439
+ },
272
440
  /**
273
441
  * Move every item into another grid's arrangement — a grid into a ring, a ring
274
442
  * onto a sphere — and keep following it. An empty `target` morphs back to
275
443
  * this grid's own.
276
444
  */
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
- }
445
+ morphInto: {
446
+ args: { target: nodeRefArg(ITEM_GRID_TYPES), ...staggerArgs(TRANSITION_STAGGER) },
447
+ run: (timeline, s, children) => transitionTo(timeline as never, s, children, { arrangeLike: timeline.args.target ?? "" }),
448
+ },
449
+ })
450
+
451
+ // ---- the one pass ------------------------------------------------------------
452
+
453
+ interface Grid {
454
+ readonly state: GridValues
455
+ readonly arranger: Arranger<never>
456
+ readonly children: readonly ChildView[]
457
+ /** Whether a view is this grid itself, which borrows nothing from itself. */
458
+ readonly isSelf: (view: NodeView) => boolean
459
+ }
345
460
 
346
- override _placementOf(child: Node2D): ChildPlacement | null {
347
- return this._placements.get(child) ?? null
348
- }
461
+ function arrangerOf(key: string): Arranger<never> {
462
+ const grid = GRIDS.get(key)
463
+ if (!grid) throw new Error(`"${key}" is no grid: pass its component through ItemGrid.define.`)
464
+ return grid.arranger
465
+ }
349
466
 
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
- }
467
+ /** A grid `view` names, when it is one this kit can arrange and not the reader itself. */
468
+ function gridIn(view: NodeView | null | undefined, grid: Grid): { view: NodeView; arranger: Arranger<never> } | null {
469
+ if (!view) return null
470
+ const found = GRIDS.get(view.key)
471
+ if (!found || grid.isSelf(view)) return null
472
+ return { view, arranger: found.arranger }
473
+ }
370
474
 
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
475
+ /** In layout, a view is this grid when it holds the same children: a child has one parent. */
476
+ function sharesChildren(children: readonly ChildView[]): (view: NodeView) => boolean {
477
+ return (view) => {
478
+ if (children.length === 0) return false
479
+ const theirs = (view.state as { readonly children?: readonly ChildView[] }).children
480
+ return theirs?.[0] === children[0]
385
481
  }
482
+ }
386
483
 
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
- }
484
+ function commandGrid(timeline: Timeline<object, never>, s: GridValues, children: readonly ChildView[]): Grid {
485
+ return { state: s, arranger: arrangerOf(timeline.node.key), children, isSelf: (view) => view.id === timeline.node.id }
486
+ }
394
487
 
395
- /** The item sizes the last layout found. */
396
- protected _itemSizes(): readonly Size2D[] {
397
- return this._sizes.length === this._itemCount() ? this._sizes : []
488
+ function snapshot(timeline: Timeline<object, never>, s: GridValues, children: readonly ChildView[]): GridSnapshot {
489
+ const grid = commandGrid(timeline, s, children)
490
+ const source = gridIn(s.arrangeLike, grid)
491
+ return {
492
+ arrangement: source
493
+ ? source.arranger.arrangement(source.view.state as never, viewBox(source.view))
494
+ : grid.arranger.arrangement(s as never, contentBox(timeline.node.size, s.padding)),
495
+ source: source?.view.id ?? "",
496
+ order: normalizeOrder(s.order, children.length),
497
+ spread: s.spread,
398
498
  }
499
+ }
399
500
 
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
- }
501
+ function measureItems(children: readonly ChildView[], space: Size2D, s: unknown, arranger: Arranger<never>): Size2D[] {
502
+ const sizes = children.map((child) => child.measure(space))
503
+ const cell = arranger.itemSpace ? arranger.itemSpace(s as never, children.length, space) : space
504
+ if (cell === space) return sizes
505
+ return children.map((child) => child.measure(cell))
506
+ }
404
507
 
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
- }
508
+ /** The arrangement the grid is heading for: the one it borrows, or its own. */
509
+ function arrangementOf(grid: Grid, box: Size2D): Arrangement {
510
+ const source = gridIn(grid.state.arrangeLike, grid)
511
+ return source ? source.arranger.arrangement(source.view.state as never, viewBox(source.view)) : grid.arranger.arrangement(grid.state as never, box)
512
+ }
420
513
 
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
- }
514
+ /** Where each item sits in the arrangement it is heading for, before any transition or effect. */
515
+ function targetSlots(grid: Grid, count: number, sizes: readonly Size2D[], box: Size2D, time: number): { slots: Slot[]; arrangement: Arrangement } {
516
+ const s = grid.state
517
+ const source = gridIn(s.arrangeLike, grid)
518
+ const sourceBox = source ? viewBox(source.view) : box
519
+ const arrangement = source ? source.arranger.arrangement(source.view.state as never, sourceBox) : grid.arranger.arrangement(s as never, box)
520
+ let slots = slotsOf(arrangement, { count, sizes, box: sourceBox, time })
521
+ if (source) slots = inFrame(source.view.frame, slots)
522
+ const slotOf = slotOfItem(normalizeOrder(s.order, count))
523
+ return { slots: slotOf.map((k) => spreadSlot(slots[k], s.spread)), arrangement }
524
+ }
443
525
 
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,
526
+ /** Each item's final slot this frame: the target, blended from any transition, with its effects on. */
527
+ function resolve(grid: Grid, sizes: readonly Size2D[], box: Size2D, time: number): Slot[] {
528
+ const s = grid.state
529
+ const count = sizes.length
530
+ const slots = targetSlots(grid, count, sizes, box, time).slots
531
+ const transition = s.transition
532
+ if (transition) {
533
+ const { from } = transition
534
+ const named = from.source ? s.transitionSource : null
535
+ const source = named && named.id === from.source && GRIDS.has(named.key) ? named : null
536
+ const self = source !== null && grid.isSelf(source)
537
+ const fromBox = source && !self ? viewBox(source) : box
538
+ let leaving = slotsOf(from.arrangement, { count, sizes, box: fromBox, time })
539
+ if (source && !self) leaving = inFrame(source.frame, leaving)
540
+ const slotOf = slotOfItem(normalizeOrder(from.order, count))
541
+ for (let i = 0; i < count; i++) {
542
+ slots[i] = blendSlot(spreadSlot(leaving[slotOf[i]], from.spread), slots[i], transition.mix[i] ?? 1)
451
543
  }
452
544
  }
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 }
545
+ const reveal = s.revealState
546
+ const wave = s.waveState
547
+ const focus = s.focusState
548
+ if (reveal || wave || focus) {
549
+ for (let i = 0; i < count; i++) applyItemEffects(slots[i], i, sizes[i], reveal, wave, focus)
469
550
  }
551
+ return slots
552
+ }
470
553
 
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
- }
554
+ /** The camera distance the grid is seen through: the target's, or, while a morph runs, the one it is leaving. */
555
+ function focalOf(s: GridValues, target: Arrangement): number {
556
+ // A morph into or out of depth keeps the camera for its whole length.
557
+ return focalLengthOf(target) || (s.transition ? focalLengthOf(s.transition.from.arrangement) : 0)
558
+ }
500
559
 
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
560
+ function cameraAt(focal: number): SpaceCamera {
561
+ return {
562
+ perspective: focal,
563
+ orbit: 0,
564
+ elevation: 0,
565
+ dolly: 0,
566
+ lookAt: CENTRE,
567
+ zoom: 1,
568
+ heading: 0,
569
+ viewportX: 0,
570
+ viewportY: 0,
508
571
  }
572
+ }
509
573
 
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)
574
+ /** A slot as a child's placement: its box centred on the slot, and the slot's transform when it has one. */
575
+ function placement(inner: { x: number; y: number }, s: Slot, size: Size2D): Placement2D {
576
+ const box = { x: inner.x + s.x, y: s.y - inner.y, width: size.width, height: size.height }
577
+ if (s.rotation === 0 && s.scale === 1 && s.rotationX === 0 && s.rotationY === 0 && s.depth === 0 && s.opacity === 1) return box
578
+ return {
579
+ ...box,
580
+ rotation: s.rotation,
581
+ scale: s.scale,
582
+ rotationX: s.rotationX,
583
+ rotationY: s.rotationY,
584
+ depth: s.depth,
585
+ opacity: s.opacity,
515
586
  }
587
+ }
516
588
 
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
- }
589
+ /** `slots`, laid out in another grid's frame, moved into this grid's. */
590
+ function inFrame(frame: NodeFrame, slots: Slot[]): Slot[] {
591
+ return slots.map((s) => {
592
+ const p = rotateClockwise(s.x * frame.scale, s.y * frame.scale, frame.rotation)
593
+ return {
594
+ ...s,
595
+ x: frame.x + p.x,
596
+ y: frame.y + p.y,
597
+ depth: s.depth * frame.scale,
598
+ rotation: s.rotation + frame.rotation,
599
+ scale: s.scale * frame.scale,
600
+ }
601
+ })
602
+ }
526
603
 
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
- }
604
+ function viewBox(view: NodeView): Size2D {
605
+ return contentBox(view.size, (view.state as { readonly padding: InsetsResolved }).padding)
542
606
  }
543
607
 
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())
608
+ /** A box of `size` inside `padding`: what a command reads as its grid's content box from `timeline.node.size`. */
609
+ export function contentBox(size: Size2D, padding: InsetsResolved): Size2D {
610
+ const inner = insetsOps.insetBounds(size.width, size.height, padding)
548
611
  return { width: inner.width, height: inner.height }
549
612
  }
550
613