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

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 +41 -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,94 @@
1
+ import { slot, type Slot, type SlotInput } from "./slot"
2
+
3
+ /** `outward` lays each item on the sphere's surface; `billboard` keeps every item facing the viewer. */
4
+ export const SPHERE_FACINGS = ["outward", "billboard"] as const
5
+ export type SphereFacing = (typeof SPHERE_FACINGS)[number]
6
+
7
+ export const SPHERE_AXES = ["x", "y", "z"] as const
8
+ export type SphereAxis = (typeof SPHERE_AXES)[number]
9
+
10
+ export interface SphereArrangement {
11
+ kind: "sphere"
12
+ radius: number
13
+ /** Turn about the vertical axis, degrees — positive carries the front to the right. */
14
+ yaw: number
15
+ /** Turn about the horizontal axis, degrees — positive brings the top toward the viewer. */
16
+ pitch: number
17
+ /** Turn about the view axis, degrees clockwise. */
18
+ roll: number
19
+ facing: SphereFacing
20
+ /** The camera's distance from the sphere's centre plane, px — smaller is more dramatic. */
21
+ focalLength: number
22
+ /** How much the back half fades, `0..1`. */
23
+ depthFade: number
24
+ /** Hide the items whose faces point away from the viewer. */
25
+ cullBackfaces: boolean
26
+ /** Degrees per second of yaw the sphere turns on its own. */
27
+ spinSpeed: number
28
+ }
29
+
30
+ const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
31
+ const DEG = 180 / Math.PI
32
+
33
+ /** Point `k` of `count` on the unit sphere — a Fibonacci lattice, as even as `count` points get. */
34
+ export function fibonacciPoint(k: number, count: number): [number, number, number] {
35
+ if (count <= 1) return [0, 0, 1]
36
+ const y = 1 - (2 * (k + 0.5)) / count
37
+ const r = Math.sqrt(Math.max(0, 1 - y * y))
38
+ const theta = k * GOLDEN_ANGLE
39
+ return [Math.cos(theta) * r, y, Math.sin(theta) * r]
40
+ }
41
+
42
+ /** `p` turned by yaw, then pitch, then roll — y-up, z toward the viewer. */
43
+ export function orient(p: readonly [number, number, number], yaw: number, pitch: number, roll: number): [number, number, number] {
44
+ const [x0, y0, z0] = p
45
+ const ya = yaw / DEG, pa = pitch / DEG, ra = roll / DEG
46
+ const x1 = x0 * Math.cos(ya) + z0 * Math.sin(ya)
47
+ const z1 = -x0 * Math.sin(ya) + z0 * Math.cos(ya)
48
+ const y2 = y0 * Math.cos(pa) - z1 * Math.sin(pa)
49
+ const z2 = y0 * Math.sin(pa) + z1 * Math.cos(pa)
50
+ return [x1 * Math.cos(ra) + y2 * Math.sin(ra), -x1 * Math.sin(ra) + y2 * Math.cos(ra), z2]
51
+ }
52
+
53
+ export function sphereSlots(arrangement: SphereArrangement, input: SlotInput): Slot[] {
54
+ const { count } = input
55
+ const radius = arrangement.radius
56
+ const yaw = arrangement.yaw + arrangement.spinSpeed * input.time
57
+ const outward = arrangement.facing === "outward"
58
+ const slots: Slot[] = []
59
+ for (let k = 0; k < count; k++) {
60
+ const q = fibonacciPoint(k, count)
61
+ const [nx, ny, nz] = orient(q, yaw, arrangement.pitch, arrangement.roll)
62
+ const s = slot(nx * radius, ny * radius, {
63
+ kind: "sphere",
64
+ lat: Math.asin(Math.max(-1, Math.min(1, q[1]))) * DEG,
65
+ lon: Math.atan2(q[2], q[0]) * DEG,
66
+ depth: nz,
67
+ })
68
+ s.depth = nz * radius
69
+ if (outward) {
70
+ // The tilt whose `Rx · Ry` turns a face's normal onto the surface normal.
71
+ s.rotationY = Math.asin(Math.max(-1, Math.min(1, nx))) * DEG
72
+ s.rotationX = Math.atan2(ny, nz) * DEG
73
+ }
74
+ s.opacity = 1 - arrangement.depthFade * (1 - (nz + 1) / 2)
75
+ if (outward && arrangement.cullBackfaces && facesAway(s, [nx, ny, nz], arrangement.focalLength)) s.opacity = 0
76
+ slots.push(s)
77
+ }
78
+ return slots
79
+ }
80
+
81
+ /** Whether an outward face at `s` shows its back to a viewer `focal` px in front of the centre. */
82
+ function facesAway(s: Slot, normal: readonly [number, number, number], focal: number): boolean {
83
+ if (focal <= 0) return normal[2] < 0
84
+ return normal[0] * -s.x + normal[1] * -s.y + normal[2] * (focal - s.depth) < 0
85
+ }
86
+
87
+ /** The yaw and pitch that bring point `index` of `count` round to face the viewer. */
88
+ export function sphereFaceForward(count: number, index: number): { yaw: number; pitch: number } {
89
+ const [x, y, z] = fibonacciPoint(index, count)
90
+ return {
91
+ yaw: -Math.atan2(x, z) * DEG,
92
+ pitch: Math.atan2(y, Math.hypot(x, z)) * DEG,
93
+ }
94
+ }
@@ -0,0 +1,96 @@
1
+ import { shuffled } from "./random"
2
+ import type { Slot } from "./slots"
3
+
4
+ /**
5
+ * The order a per-item effect sweeps through a grid's items. The first five
6
+ * work on any grid; the rest follow one arrangement's own geometry — rows and
7
+ * columns of a rectangular grid (or top-to-bottom and left-to-right of any
8
+ * other), the way round a ring, the way along a path, front to back of a sphere
9
+ * — and fall back to item order where the grid has no such thing.
10
+ */
11
+ export const STAGGER_ORDERS = [
12
+ "index",
13
+ "reverse",
14
+ "random",
15
+ "centerOut",
16
+ "edgesIn",
17
+ "row",
18
+ "column",
19
+ "angle",
20
+ "path",
21
+ "depth",
22
+ ] as const
23
+ export type StaggerOrder = (typeof STAGGER_ORDERS)[number]
24
+
25
+ /** The most of a command's length the offsets may take; the rest is each item's own motion. */
26
+ const MAX_STAGGER = 0.95
27
+
28
+ /**
29
+ * Each item's start, `0..1`, as a share of the stagger: `0` goes first, `1`
30
+ * last, and items that tie go together — a ring revealed `centerOut` from its
31
+ * centre appears all at once, which is what that means.
32
+ */
33
+ export function staggerRanks(order: StaggerOrder, slots: readonly Slot[], seed: number): number[] {
34
+ const n = slots.length
35
+ const keys = keysOf(order, slots, seed)
36
+ let min = Infinity
37
+ let max = -Infinity
38
+ for (const key of keys) {
39
+ if (key < min) min = key
40
+ if (key > max) max = key
41
+ }
42
+ const range = max - min
43
+ if (!(range > 1e-9)) return new Array<number>(n).fill(0)
44
+ return keys.map((key) => (key - min) / range)
45
+ }
46
+
47
+ /**
48
+ * Item progress at command time `t`: each item runs over the same length of
49
+ * time, starting `rank · stagger` in. The stagger lives inside the command's
50
+ * duration, so a one-second reveal takes one second for four items or four
51
+ * hundred.
52
+ */
53
+ export function staggerProgress(rank: number, t: number, stagger: number): number {
54
+ const s = Math.max(0, Math.min(MAX_STAGGER, stagger))
55
+ const p = s === 0 ? t : (t - rank * s) / (1 - s)
56
+ return p < 0 ? 0 : p > 1 ? 1 : p
57
+ }
58
+
59
+ function keysOf(order: StaggerOrder, slots: readonly Slot[], seed: number): number[] {
60
+ switch (order) {
61
+ case "reverse":
62
+ return slots.map((_, i) => -i)
63
+ case "random": {
64
+ const sequence = shuffled(slots.map((_, i) => i), seed)
65
+ const keys = new Array<number>(slots.length)
66
+ sequence.forEach((item, rank) => {
67
+ keys[item] = rank
68
+ })
69
+ return keys
70
+ }
71
+ case "centerOut":
72
+ return slots.map((s) => Math.round(Math.hypot(s.x, s.y, s.depth) * 1000) / 1000)
73
+ case "edgesIn":
74
+ return slots.map((s) => -Math.round(Math.hypot(s.x, s.y, s.depth) * 1000) / 1000)
75
+ case "row":
76
+ return slots.map((s) => (s.meta.kind === "rectangular" ? s.meta.row : -Math.round(s.y)))
77
+ case "column":
78
+ return slots.map((s) => (s.meta.kind === "rectangular" ? s.meta.column : Math.round(s.x)))
79
+ case "angle":
80
+ return slots.map((s, i) =>
81
+ s.meta.kind === "radial" ? s.meta.angle : s.meta.kind === "rectangular" || s.meta.kind === "sphere" ? clockAngle(s) : i
82
+ )
83
+ case "path":
84
+ return slots.map((s, i) => (s.meta.kind === "path" ? s.meta.t : i))
85
+ case "depth":
86
+ return slots.map((s, i) => (s.meta.kind === "sphere" ? -s.meta.depth : i))
87
+ default:
88
+ return slots.map((_, i) => i)
89
+ }
90
+ }
91
+
92
+ /** Degrees clockwise from twelve o'clock to where `s` sits. */
93
+ function clockAngle(s: Slot): number {
94
+ const angle = (Math.atan2(s.x, s.y) * 180) / Math.PI
95
+ return angle < 0 ? angle + 360 : angle
96
+ }
package/src/nodes.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { PathGrid } from "./path-grid"
2
+ import { RadialGrid } from "./radial-grid"
3
+ import { RectangularGrid } from "./rectangular-grid"
4
+ import { SphereGrid } from "./sphere-grid"
5
+
6
+ /**
7
+ * Every node type this package publishes.
8
+ *
9
+ * A host registers these by handing the array to an engine —
10
+ * `new Engine(platform, { nodes: NODES })` — which reads each class's `@node()`
11
+ * key. Listing class **values** is also what survives bundling: this package
12
+ * declares `sideEffects: false`, and a document names a node type by string, so
13
+ * an import kept only for its side effect is exactly what gets shaken out.
14
+ */
15
+ export const NODES = [RectangularGrid, RadialGrid, PathGrid, SphereGrid]
@@ -0,0 +1 @@
1
+ export * from "./path-grid"
@@ -0,0 +1,213 @@
1
+ import {
2
+ command,
3
+ node,
4
+ Path,
5
+ pathOps,
6
+ PathShape,
7
+ pathProperty,
8
+ property,
9
+ type Command,
10
+ type CommandArgs,
11
+ type NodeConfig,
12
+ type PathData,
13
+ type Size2D,
14
+ } from "@motionscript/sdk"
15
+
16
+ import {
17
+ findNode2D,
18
+ frameIn,
19
+ ItemGrid,
20
+ PATH_SPACINGS,
21
+ PATH_TEMPLATES,
22
+ staggerArgs,
23
+ TRANSITION_STAGGER,
24
+ templatePath,
25
+ type ItemGridProps,
26
+ type PathArrangement,
27
+ type PathPlacement,
28
+ type PathSpacing,
29
+ type PathTemplate,
30
+ type StaggerArgs,
31
+ } from "../kit"
32
+
33
+ /** A template, or `custom` for the grid's own {@link PathGrid.path}. */
34
+ export const PATH_GRID_SHAPES = [...PATH_TEMPLATES, "custom"] as const
35
+ export type PathGridShape = (typeof PATH_GRID_SHAPES)[number]
36
+
37
+ export interface PathGridProps extends ItemGridProps {
38
+ shape: PathGridShape
39
+ path: PathData
40
+ pathNode: string
41
+ spacing: PathSpacing
42
+ spacingDistance: number
43
+ offset: number
44
+ flowSpeed: number
45
+ trimStart: number
46
+ trimEnd: number
47
+ alignToPath: boolean
48
+ }
49
+
50
+ const AT_ORIGIN: PathPlacement = { originX: 0, originY: 0, x: 0, y: 0, rotation: 0, scale: 1 }
51
+
52
+ /** The size a path grid is placed at when nothing says otherwise, since a template is drawn to fill it. */
53
+ const DEFAULT_SIZE = { width: 640, height: 400 }
54
+
55
+ /**
56
+ * Children along a path: one of the templates drawn to fill the node's box,
57
+ * a path of the grid's own, or a `Path` node elsewhere in the scene, followed
58
+ * wherever it is.
59
+ *
60
+ * Items are spaced by distance along the curve, so they sit evenly on a tight
61
+ * bend and a straight alike. Animating {@link offset} moves them along it — on
62
+ * a closed path they come round again, which is a conveyor belt for free.
63
+ */
64
+ @node({
65
+ key: "pathGrid",
66
+ parentKey: "node",
67
+ forkable: true,
68
+ layout: {
69
+ children: "freeform",
70
+ acceptsChildren: true,
71
+ defaultWidthMode: "fixed",
72
+ defaultHeightMode: "fixed",
73
+ defaultWidth: DEFAULT_SIZE.width,
74
+ defaultHeight: DEFAULT_SIZE.height,
75
+ },
76
+ seed: { ...DEFAULT_SIZE },
77
+ })
78
+ export class PathGrid extends ItemGrid<PathGridProps> {
79
+ @property({ default: "circle", kind: "enum", options: PATH_GRID_SHAPES }) declare shape: PathGridShape
80
+ /** Used when {@link shape} is `custom`: an SVG path, y-down as SVG writes one, centred on its own bounds. */
81
+ @pathProperty() declare path: PathData
82
+ /** A path node to follow instead, by id — the items track it wherever it goes. */
83
+ @property({ default: "", kind: "nodeRef", nodeTypes: ["path"], animatable: false }) declare pathNode: string
84
+ @property({ default: "distribute", kind: "enum", options: PATH_SPACINGS }) declare spacing: PathSpacing
85
+ @property({ default: 80, min: 0, step: 1 }) declare spacingDistance: number
86
+ /** Share of the path the items are moved along — `1` is once round. */
87
+ @property({ default: 0, step: 0.01 }) declare offset: number
88
+ /** Share of the path per second the items travel on their own. */
89
+ @property({ default: 0, step: 0.01, unit: "/s" }) declare flowSpeed: number
90
+ @property({ default: 0, min: 0, max: 1, step: 0.01 }) declare trimStart: number
91
+ @property({ default: 1, min: 0, max: 1, step: 0.01 }) declare trimEnd: number
92
+ @property({ default: true }) declare alignToPath: boolean
93
+
94
+ constructor(props?: NodeConfig<PathGrid, PathGridProps>) {
95
+ super(props)
96
+ }
97
+
98
+ /** Move the items along the path by `loops` — `1` is once round, and they keep their spacing. */
99
+ @command({ args: [{ key: "loops", step: 0.25, default: 1 }] })
100
+ flow(args: CommandArgs<{ loops?: number }> & { duration: number }): Command<PathGridProps> {
101
+ const loops = args.data?.loops ?? 1
102
+ return this.to({ data: { offset: this.offset + loops }, duration: args.duration, easing: args.easing })
103
+ }
104
+
105
+ /** Squeeze the items into part of the path, between two shares of its length. */
106
+ @command({
107
+ args: [
108
+ { key: "trimStart", min: 0, max: 1, step: 0.01, default: 0.25 },
109
+ { key: "trimEnd", min: 0, max: 1, step: 0.01, default: 0.75 },
110
+ ],
111
+ })
112
+ trimTo(args: CommandArgs<{ trimStart?: number; trimEnd?: number }> & { duration: number }): Command<PathGridProps> {
113
+ return this.to({
114
+ data: { trimStart: args.data?.trimStart ?? this.trimStart, trimEnd: args.data?.trimEnd ?? this.trimEnd },
115
+ duration: args.duration,
116
+ easing: args.easing,
117
+ })
118
+ }
119
+
120
+ /**
121
+ * Move the items onto another path — a template, or a `Path` node in the
122
+ * scene. Each item travels from its place on the old path to its place on the
123
+ * new one; the path itself is not morphed.
124
+ */
125
+ @command({
126
+ args: [
127
+ { key: "shape", kind: "enum", options: PATH_GRID_SHAPES, default: "heart" },
128
+ { key: "pathNode", kind: "nodeRef", nodeTypes: ["path"] },
129
+ ...staggerArgs(TRANSITION_STAGGER),
130
+ ],
131
+ })
132
+ setPath(args: CommandArgs<{ shape?: PathGridShape; pathNode?: string } & StaggerArgs> & { duration: number }): Command<PathGridProps> {
133
+ const pathNode = args.data?.pathNode ?? ""
134
+ const changes: Partial<PathGridProps> = pathNode ? { pathNode } : { shape: args.data?.shape ?? this.shape, pathNode: "" }
135
+ return this._transitionTo(changes, args)
136
+ }
137
+
138
+ protected override arrangementFor(box: Size2D): PathArrangement {
139
+ const { path, placement } = this._pathIn(box)
140
+ return {
141
+ kind: "path",
142
+ path,
143
+ placement,
144
+ spacing: this.spacing,
145
+ spacingDistance: this.spacingDistance,
146
+ offset: this.offset,
147
+ flowSpeed: this.flowSpeed,
148
+ trimStart: this.trimStart,
149
+ trimEnd: this.trimEnd,
150
+ alignToPath: this.alignToPath,
151
+ }
152
+ }
153
+
154
+ // A template is drawn to fill the box, so a path grid needs one of its own
155
+ // rather than hugging items whose places depend on it.
156
+ protected override applyDefaultSize(props?: { readonly width?: unknown; readonly height?: unknown }): void {
157
+ if (!props || props.width === undefined) this.applyProp("width", DEFAULT_SIZE.width)
158
+ if (!props || props.height === undefined) this.applyProp("height", DEFAULT_SIZE.height)
159
+ }
160
+
161
+ private _linked: { data: PathData; width: number; height: number; path: PathData } | null = null
162
+
163
+ /** The path the items follow, and where its coordinates land in this grid's frame. */
164
+ private _pathIn(box: Size2D): { path: PathData; placement: PathPlacement } {
165
+ const linked = this.pathNode ? findNode2D(this, this.pathNode) : null
166
+ if (linked instanceof PathShape) {
167
+ return { path: this._unitPath(linked), placement: { ...AT_ORIGIN, ...frameIn(this, linked) } }
168
+ }
169
+ if (linked instanceof Path && pathOps.bounds(linked.data)) {
170
+ // A `Path` draws its data centred on the data's own bounds, at its own position.
171
+ const bounds = pathOps.bounds(linked.data)!
172
+ const frame = frameIn(this, linked)
173
+ return {
174
+ path: linked.data,
175
+ placement: {
176
+ originX: (bounds.minX + bounds.maxX) / 2,
177
+ originY: (bounds.minY + bounds.maxY) / 2,
178
+ ...frame,
179
+ },
180
+ }
181
+ }
182
+ if (this.shape === "custom") {
183
+ const bounds = pathOps.bounds(this.path)
184
+ if (bounds) {
185
+ return {
186
+ path: this.path,
187
+ placement: {
188
+ ...AT_ORIGIN,
189
+ originX: (bounds.minX + bounds.maxX) / 2,
190
+ originY: (bounds.minY + bounds.maxY) / 2,
191
+ flipY: true,
192
+ },
193
+ }
194
+ }
195
+ }
196
+ const template: PathTemplate = this.shape === "custom" ? "circle" : this.shape
197
+ return { path: templatePath(template, box), placement: AT_ORIGIN }
198
+ }
199
+
200
+ /**
201
+ * A path node's geometry as it draws it: stored in its unit box, y-down, and
202
+ * scaled to its laid-out size — kept while neither changes, so the path's
203
+ * measurement is kept with it.
204
+ */
205
+ private _unitPath(shape: PathShape): PathData {
206
+ const { width, height } = shape.layoutBounds
207
+ const cached = this._linked
208
+ if (cached && cached.data === shape.data && cached.width === width && cached.height === height) return cached.path
209
+ const path = pathOps.anchorCommands(pathOps.anchors(shape.data), (p) => ({ x: p.x * width, y: -p.y * height }))
210
+ this._linked = { data: shape.data, width, height, path }
211
+ return path
212
+ }
213
+ }
@@ -0,0 +1 @@
1
+ export * from "./radial-grid"
@@ -0,0 +1,90 @@
1
+ import { command, node, property, type Command, type CommandArgs, type NodeConfig } from "@motionscript/sdk"
2
+
3
+ import {
4
+ ItemGrid,
5
+ normalizeOrder,
6
+ RADIAL_DIRECTIONS,
7
+ radialTurnToTop,
8
+ type ItemGridProps,
9
+ type RadialArrangement,
10
+ type RadialDirection,
11
+ } from "../kit"
12
+
13
+ export interface RadialGridProps extends ItemGridProps {
14
+ radius: number
15
+ startAngle: number
16
+ sweep: number
17
+ direction: RadialDirection
18
+ rotateItems: boolean
19
+ spinSpeed: number
20
+ }
21
+
22
+ /**
23
+ * Children round a circle, or along an arc of one.
24
+ *
25
+ * A full circle spaces the items evenly with none on top of another; an arc
26
+ * puts one at each end. Angles run clockwise from twelve o'clock, so
27
+ * {@link turnTo} can bring any item to the top like a dial.
28
+ */
29
+ @node({
30
+ key: "radialGrid",
31
+ parentKey: "node",
32
+ forkable: true,
33
+ layout: {
34
+ children: "freeform",
35
+ acceptsChildren: true,
36
+ defaultWidthMode: "hug",
37
+ defaultHeightMode: "hug",
38
+ },
39
+ })
40
+ export class RadialGrid extends ItemGrid<RadialGridProps> {
41
+ @property({ default: 200, min: 0, step: 1 }) declare radius: number
42
+ @property({ default: 0, step: 1, unit: "°" }) declare startAngle: number
43
+ @property({ default: 360, min: -360, max: 360, step: 1, unit: "°" }) declare sweep: number
44
+ @property({ default: "clockwise", kind: "enum", options: RADIAL_DIRECTIONS }) declare direction: RadialDirection
45
+ @property({ default: false }) declare rotateItems: boolean
46
+ @property({ default: 0, step: 1, unit: "°/s" }) declare spinSpeed: number
47
+
48
+ constructor(props?: NodeConfig<RadialGrid, RadialGridProps>) {
49
+ super(props)
50
+ }
51
+
52
+ /** Orbit the items round the centre by `degrees` — `360` is one full turn. */
53
+ @command({ args: [{ key: "degrees", step: 15, unit: "°", default: 360 }] })
54
+ spin(args: CommandArgs<{ degrees?: number }> & { duration: number }): Command<RadialGridProps> {
55
+ const degrees = args.data?.degrees ?? 360
56
+ return this.to({ data: { startAngle: this.startAngle + degrees }, duration: args.duration, easing: args.easing })
57
+ }
58
+
59
+ /** Turn the ring the shorter way round until an item (by its place in the layers) sits at the top. */
60
+ @command({ args: [{ key: "item", min: 0, step: 1, default: 0 }] })
61
+ turnTo(args: CommandArgs<{ item?: number }> & { duration: number }): Command<RadialGridProps> {
62
+ const count = this._itemCount()
63
+ const slot = normalizeOrder(this.order, count).indexOf(Math.round(args.data?.item ?? 0))
64
+ const turn = slot < 0 ? 0 : radialTurnToTop(this.arrangementFor(), count, slot)
65
+ return this.to({ data: { startAngle: this.startAngle + turn }, duration: args.duration, easing: args.easing })
66
+ }
67
+
68
+ @command({ args: [{ key: "radius", min: 0, default: 280 }] })
69
+ radiusTo(args: CommandArgs<{ radius: number }> & { duration: number }): Command<RadialGridProps> {
70
+ return this.to({ data: { radius: args.data?.radius ?? this.radius }, duration: args.duration, easing: args.easing })
71
+ }
72
+
73
+ /** Open or close the arc like a fan — from `0` to `360` is a reveal of its own. */
74
+ @command({ args: [{ key: "sweep", min: -360, max: 360, unit: "°", default: 360 }] })
75
+ sweepTo(args: CommandArgs<{ sweep: number }> & { duration: number }): Command<RadialGridProps> {
76
+ return this.to({ data: { sweep: args.data?.sweep ?? this.sweep }, duration: args.duration, easing: args.easing })
77
+ }
78
+
79
+ protected override arrangementFor(): RadialArrangement {
80
+ return {
81
+ kind: "radial",
82
+ radius: this.radius,
83
+ startAngle: this.startAngle,
84
+ sweep: this.sweep,
85
+ direction: this.direction,
86
+ rotateItems: this.rotateItems,
87
+ spinSpeed: this.spinSpeed,
88
+ }
89
+ }
90
+ }
@@ -0,0 +1 @@
1
+ export * from "./rectangular-grid"
@@ -0,0 +1,160 @@
1
+ import {
2
+ command,
3
+ easeInOut,
4
+ node,
5
+ property,
6
+ type Command,
7
+ type CommandArgs,
8
+ type NodeConfig,
9
+ type Size2D,
10
+ } from "@motionscript/sdk"
11
+
12
+ import {
13
+ CELL_MODES,
14
+ contentBoxOf,
15
+ FILL_DIRECTIONS,
16
+ LINE_ALIGNS,
17
+ ItemGrid,
18
+ rectangularCell,
19
+ staggerArgs,
20
+ TRANSITION_STAGGER,
21
+ type CellMode,
22
+ type FillDirection,
23
+ type ItemGridProps,
24
+ type LineAlign,
25
+ type RectangularArrangement,
26
+ type StaggerArgs,
27
+ } from "../kit"
28
+
29
+ export interface RectangularGridProps extends ItemGridProps {
30
+ columns: number
31
+ fillDirection: FillDirection
32
+ cellMode: CellMode
33
+ cellWidth: number
34
+ cellHeight: number
35
+ columnGap: number
36
+ rowGap: number
37
+ lastLineAlign: LineAlign
38
+ rowOffset: number
39
+ columnOffset: number
40
+ }
41
+
42
+ /**
43
+ * Children in rows and columns — the grid the other three are variations on.
44
+ *
45
+ * Cells are as big as the largest child (`fit`), a size of their own
46
+ * (`fixed`), or an equal share of the node's box (`fill`), which is also the
47
+ * room a child set to fill grows into. Shift every other row by half a cell
48
+ * for a brick bond or a hex pack.
49
+ */
50
+ @node({
51
+ key: "rectangularGrid",
52
+ parentKey: "node",
53
+ forkable: true,
54
+ layout: {
55
+ children: "freeform",
56
+ acceptsChildren: true,
57
+ defaultWidthMode: "hug",
58
+ defaultHeightMode: "hug",
59
+ },
60
+ })
61
+ export class RectangularGrid extends ItemGrid<RectangularGridProps> {
62
+ @property({ default: 3, min: 1, step: 1 }) declare columns: number
63
+ @property({ default: "row", kind: "enum", options: FILL_DIRECTIONS }) declare fillDirection: FillDirection
64
+ @property({ default: "fit", kind: "enum", options: CELL_MODES }) declare cellMode: CellMode
65
+ @property({ default: 120, min: 0, step: 1 }) declare cellWidth: number
66
+ @property({ default: 120, min: 0, step: 1 }) declare cellHeight: number
67
+ @property({ default: 24, step: 1 }) declare columnGap: number
68
+ @property({ default: 24, step: 1 }) declare rowGap: number
69
+ @property({ default: "start", kind: "enum", options: LINE_ALIGNS }) declare lastLineAlign: LineAlign
70
+ @property({ default: 0, min: -1, max: 1, step: 0.05 }) declare rowOffset: number
71
+ @property({ default: 0, min: -1, max: 1, step: 0.05 }) declare columnOffset: number
72
+
73
+ constructor(props?: NodeConfig<RectangularGrid, RectangularGridProps>) {
74
+ super(props)
75
+ }
76
+
77
+ /** Re-flow the items into `columns` columns, each moving to its new cell. */
78
+ @command({ args: [{ key: "columns", min: 1, step: 1, default: 4 }, ...staggerArgs(TRANSITION_STAGGER)] })
79
+ columnsTo(args: CommandArgs<{ columns: number } & StaggerArgs> & { duration: number }): Command<RectangularGridProps> {
80
+ const columns = Math.max(1, Math.round(args.data?.columns ?? this.columns))
81
+ return this._transitionTo({ columns }, args)
82
+ }
83
+
84
+ /** Animate the spacing between columns and rows. */
85
+ @command({ args: [{ key: "columnGap", default: 48 }, { key: "rowGap", default: 48 }] })
86
+ gapTo(args: CommandArgs<{ columnGap?: number; rowGap?: number }> & { duration: number }): Command<RectangularGridProps> {
87
+ const data: Partial<RectangularGridProps> = {}
88
+ if (args.data?.columnGap !== undefined) data.columnGap = args.data.columnGap
89
+ if (args.data?.rowGap !== undefined) data.rowGap = args.data.rowGap
90
+ return this.to({ data, duration: args.duration, easing: args.easing })
91
+ }
92
+
93
+ /**
94
+ * Animate the cell size, from whatever the cells measure now. The grid
95
+ * switches to `fixed` cells as it starts, since a size of its own is what
96
+ * `fixed` means.
97
+ */
98
+ @command({ args: [{ key: "cellWidth", min: 0, default: 160 }, { key: "cellHeight", min: 0, default: 160 }] })
99
+ cellSizeTo(args: CommandArgs<{ cellWidth?: number; cellHeight?: number }> & { duration: number }): Command<RectangularGridProps> {
100
+ const sizes = this._itemSizes()
101
+ const from = rectangularCell(this.arrangementFor(), sizes.length, sizes, contentBoxOf(this))
102
+ const width = args.data?.cellWidth ?? from.width
103
+ const height = args.data?.cellHeight ?? from.height
104
+ return this.command<RectangularGridProps>((t) => ({
105
+ cellMode: "fixed",
106
+ cellWidth: from.width + (width - from.width) * t,
107
+ cellHeight: from.height + (height - from.height) * t,
108
+ }), args.duration, args.easing ?? easeInOut("cubic"))
109
+ }
110
+
111
+ /** Bring one row (from 0 at the top) forward and push the rest back. */
112
+ @command({
113
+ args: [
114
+ { key: "row", min: 0, step: 1, default: 0 },
115
+ { key: "dim", min: 0, max: 1, step: 0.05, default: 0.7 },
116
+ { key: "grow", min: 0, max: 1, step: 0.05, default: 0.1 },
117
+ ],
118
+ })
119
+ focusRow(args: CommandArgs<{ row?: number; dim?: number; grow?: number }> & { duration: number }): Command<ItemGridProps> {
120
+ const slots = this._itemSlots()
121
+ const row = Math.round(args.data?.row ?? 0)
122
+ return this._focusOn((i) => slots[i]?.meta.kind === "rectangular" && slots[i].meta.row === row, slots.length, args)
123
+ }
124
+
125
+ /** Bring one column (from 0 at the left) forward and push the rest back. */
126
+ @command({
127
+ args: [
128
+ { key: "column", min: 0, step: 1, default: 0 },
129
+ { key: "dim", min: 0, max: 1, step: 0.05, default: 0.7 },
130
+ { key: "grow", min: 0, max: 1, step: 0.05, default: 0.1 },
131
+ ],
132
+ })
133
+ focusColumn(args: CommandArgs<{ column?: number; dim?: number; grow?: number }> & { duration: number }): Command<ItemGridProps> {
134
+ const slots = this._itemSlots()
135
+ const column = Math.round(args.data?.column ?? 0)
136
+ return this._focusOn((i) => slots[i]?.meta.kind === "rectangular" && slots[i].meta.column === column, slots.length, args)
137
+ }
138
+
139
+ protected override arrangementFor(): RectangularArrangement {
140
+ return {
141
+ kind: "rectangular",
142
+ columns: this.columns,
143
+ fillDirection: this.fillDirection,
144
+ cellMode: this.cellMode,
145
+ cellWidth: this.cellWidth,
146
+ cellHeight: this.cellHeight,
147
+ columnGap: this.columnGap,
148
+ rowGap: this.rowGap,
149
+ lastLineAlign: this.lastLineAlign,
150
+ rowOffset: this.rowOffset,
151
+ columnOffset: this.columnOffset,
152
+ }
153
+ }
154
+
155
+ /** A child set to fill takes its cell, where the cell is not simply the largest child. */
156
+ protected override itemSpace(count: number, space: Size2D): Size2D {
157
+ if (this.cellMode === "fit") return space
158
+ return rectangularCell(this.arrangementFor(), count, [], space)
159
+ }
160
+ }
@@ -0,0 +1 @@
1
+ export * from "./sphere-grid"