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

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 (63) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/LICENSE +201 -201
  3. package/NOTICE +49 -48
  4. package/README.md +118 -118
  5. package/dist/browser/chunks/{chunk-J62D7EFZ.js → chunk-TYNFMZ3D.js} +1 -1
  6. package/dist/browser/chunks/chunk-TYNFMZ3D.js.map +7 -0
  7. package/dist/browser/index.js +1 -1
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/kit.js +1 -1
  10. package/dist/browser/manifest.json +1 -1
  11. package/dist/engine.d.ts +1 -1
  12. package/dist/engine.js +1 -1
  13. package/dist/engine.js.map +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/kit/index.js.map +1 -1
  16. package/dist/kit/item-effects.js.map +1 -1
  17. package/dist/kit/item-grid.js.map +1 -1
  18. package/dist/kit/order.js.map +1 -1
  19. package/dist/kit/random.js.map +1 -1
  20. package/dist/kit/slots/index.js.map +1 -1
  21. package/dist/kit/slots/path-templates.js.map +1 -1
  22. package/dist/kit/slots/path.js.map +1 -1
  23. package/dist/kit/slots/radial.js.map +1 -1
  24. package/dist/kit/slots/rectangular.js.map +1 -1
  25. package/dist/kit/slots/slot.js.map +1 -1
  26. package/dist/kit/slots/sphere.js.map +1 -1
  27. package/dist/kit/stagger.js.map +1 -1
  28. package/dist/nodes.js.map +1 -1
  29. package/dist/path-grid/index.js.map +1 -1
  30. package/dist/path-grid/path-grid.js.map +1 -1
  31. package/dist/radial-grid/index.js.map +1 -1
  32. package/dist/radial-grid/radial-grid.js.map +1 -1
  33. package/dist/rectangular-grid/index.js.map +1 -1
  34. package/dist/rectangular-grid/rectangular-grid.js.map +1 -1
  35. package/dist/sphere-grid/index.js.map +1 -1
  36. package/dist/sphere-grid/sphere-grid.js.map +1 -1
  37. package/package.json +5 -4
  38. package/registry.json +32 -32
  39. package/src/engine.ts +12 -12
  40. package/src/index.ts +6 -6
  41. package/src/kit/index.ts +18 -18
  42. package/src/kit/item-effects.ts +114 -114
  43. package/src/kit/item-grid.ts +648 -648
  44. package/src/kit/order.ts +41 -41
  45. package/src/kit/random.ts +29 -29
  46. package/src/kit/slots/index.ts +39 -39
  47. package/src/kit/slots/path-templates.ts +120 -120
  48. package/src/kit/slots/path.ts +120 -120
  49. package/src/kit/slots/radial.ts +62 -62
  50. package/src/kit/slots/rectangular.ts +108 -108
  51. package/src/kit/slots/slot.ts +82 -82
  52. package/src/kit/slots/sphere.ts +94 -94
  53. package/src/kit/stagger.ts +96 -96
  54. package/src/nodes.ts +30 -30
  55. package/src/path-grid/index.ts +1 -1
  56. package/src/path-grid/path-grid.ts +186 -186
  57. package/src/radial-grid/index.ts +1 -1
  58. package/src/radial-grid/radial-grid.ts +81 -81
  59. package/src/rectangular-grid/index.ts +1 -1
  60. package/src/rectangular-grid/rectangular-grid.ts +145 -145
  61. package/src/sphere-grid/index.ts +1 -1
  62. package/src/sphere-grid/sphere-grid.ts +129 -129
  63. package/dist/browser/chunks/chunk-J62D7EFZ.js.map +0 -7
@@ -1,120 +1,120 @@
1
- import { pathOps, type PathData, type PathSampler } from "@motionscript/sdk"
2
-
3
- import { slot, type Slot, type SlotInput } from "./slot"
4
-
5
- /** `distribute` spreads the items over the path; `fixed` sets them a distance apart. */
6
- export const PATH_SPACINGS = ["distribute", "fixed"] as const
7
- export type PathSpacing = (typeof PATH_SPACINGS)[number]
8
-
9
- /** How path coordinates land in the grid's frame: `origin` of the path goes to `(x, y)`, turned and scaled. */
10
- export interface PathPlacement {
11
- originX: number
12
- originY: number
13
- /** The path is y-down, as SVG and the editor write one. */
14
- flipY?: boolean
15
- x: number
16
- y: number
17
- /** Degrees clockwise. */
18
- rotation: number
19
- scale: number
20
- }
21
-
22
- export interface PathArrangement {
23
- kind: "path"
24
- /** y-up, like every path in the library, unless its placement says otherwise. */
25
- path: PathData
26
- placement: PathPlacement
27
- spacing: PathSpacing
28
- /** Pixels between items when `spacing` is `fixed`. */
29
- spacingDistance: number
30
- /** Share of the path the items are moved along; wraps on a closed path. */
31
- offset: number
32
- /** Share of the path per second the items travel on their own. */
33
- flowSpeed: number
34
- trimStart: number
35
- trimEnd: number
36
- /** Turn each item to follow the path's direction. */
37
- alignToPath: boolean
38
- }
39
-
40
- const SAMPLERS = new Map<PathData, { sampler: PathSampler; closed: boolean }>()
41
- const SAMPLER_CACHE = 32
42
-
43
- /** A sampler for `path`, kept while the same value keeps arriving — a path is re-read every frame. */
44
- function measured(path: PathData): { sampler: PathSampler; closed: boolean } {
45
- const hit = SAMPLERS.get(path)
46
- if (hit) return hit
47
- const contours = pathOps.contours(path)
48
- const entry = {
49
- sampler: pathOps.sampler(path),
50
- closed: contours.length === 1 && contours[0].closed,
51
- }
52
- if (SAMPLERS.size >= SAMPLER_CACHE) SAMPLERS.delete(SAMPLERS.keys().next().value!)
53
- SAMPLERS.set(path, entry)
54
- return entry
55
- }
56
-
57
- /**
58
- * Items at even arc-length steps — by distance along the curve rather than the
59
- * path's own parameter, which bunches them up wherever a curve is tight.
60
- *
61
- * On a closed path used whole, `n` items take `n` equal steps and `offset`
62
- * wraps round, so an animated offset is a loop. Anywhere else the ends are
63
- * ends: `n` items take `n - 1` steps so both get one, and an item moved past
64
- * either is hidden rather than piled on it.
65
- */
66
- export function pathSlots(arrangement: PathArrangement, input: SlotInput): Slot[] {
67
- const { count } = input
68
- if (count === 0) return []
69
- const { sampler, closed } = measured(arrangement.path)
70
- const length = sampler.length
71
- const a = clamp01(Math.min(arrangement.trimStart, arrangement.trimEnd))
72
- const b = clamp01(Math.max(arrangement.trimStart, arrangement.trimEnd))
73
- const start = a * length
74
- const span = (b - a) * length
75
- const loops = closed && a === 0 && b === 1
76
- const offset = arrangement.offset + arrangement.flowSpeed * input.time
77
- const place = arrangement.placement
78
- const rad = (place.rotation * Math.PI) / 180
79
- const cos = Math.cos(rad)
80
- const sin = Math.sin(rad)
81
-
82
- const slots: Slot[] = []
83
- for (let k = 0; k < count; k++) {
84
- let u: number
85
- if (arrangement.spacing === "fixed" && span > 0) {
86
- u = offset + (k * arrangement.spacingDistance) / span
87
- } else if (loops) {
88
- u = offset + k / count
89
- } else {
90
- u = offset + (count === 1 ? 0.5 : k / (count - 1))
91
- }
92
- let visible = true
93
- if (loops) u = u - Math.floor(u)
94
- else if (u < -1e-9 || u > 1 + 1e-9) visible = false
95
- const at = sampler.frameAt(start + clamp01(u) * span)
96
- if (!at) {
97
- const s = slot(0, 0, { kind: "path", t: clamp01(u) })
98
- s.opacity = 0
99
- slots.push(s)
100
- continue
101
- }
102
- // Into the grid's frame: about the path's origin, turned clockwise (y-up).
103
- const flip = place.flipY ? -1 : 1
104
- const px = (at.x - place.originX) * place.scale
105
- const py = (at.y - place.originY) * place.scale * flip
106
- const s = slot(
107
- place.x + px * cos + py * sin,
108
- place.y - px * sin + py * cos,
109
- { kind: "path", t: clamp01(u) },
110
- )
111
- if (arrangement.alignToPath) s.rotation = (-Math.atan2(at.ty * flip, at.tx) * 180) / Math.PI + place.rotation
112
- if (!visible) s.opacity = 0
113
- slots.push(s)
114
- }
115
- return slots
116
- }
117
-
118
- function clamp01(v: number): number {
119
- return v < 0 ? 0 : v > 1 ? 1 : v
120
- }
1
+ import { pathOps, type PathData, type PathSampler } from "@motionscript/sdk"
2
+
3
+ import { slot, type Slot, type SlotInput } from "./slot"
4
+
5
+ /** `distribute` spreads the items over the path; `fixed` sets them a distance apart. */
6
+ export const PATH_SPACINGS = ["distribute", "fixed"] as const
7
+ export type PathSpacing = (typeof PATH_SPACINGS)[number]
8
+
9
+ /** How path coordinates land in the grid's frame: `origin` of the path goes to `(x, y)`, turned and scaled. */
10
+ export interface PathPlacement {
11
+ originX: number
12
+ originY: number
13
+ /** The path is y-down, as SVG and the editor write one. */
14
+ flipY?: boolean
15
+ x: number
16
+ y: number
17
+ /** Degrees clockwise. */
18
+ rotation: number
19
+ scale: number
20
+ }
21
+
22
+ export interface PathArrangement {
23
+ kind: "path"
24
+ /** y-up, like every path in the library, unless its placement says otherwise. */
25
+ path: PathData
26
+ placement: PathPlacement
27
+ spacing: PathSpacing
28
+ /** Pixels between items when `spacing` is `fixed`. */
29
+ spacingDistance: number
30
+ /** Share of the path the items are moved along; wraps on a closed path. */
31
+ offset: number
32
+ /** Share of the path per second the items travel on their own. */
33
+ flowSpeed: number
34
+ trimStart: number
35
+ trimEnd: number
36
+ /** Turn each item to follow the path's direction. */
37
+ alignToPath: boolean
38
+ }
39
+
40
+ const SAMPLERS = new Map<PathData, { sampler: PathSampler; closed: boolean }>()
41
+ const SAMPLER_CACHE = 32
42
+
43
+ /** A sampler for `path`, kept while the same value keeps arriving — a path is re-read every frame. */
44
+ function measured(path: PathData): { sampler: PathSampler; closed: boolean } {
45
+ const hit = SAMPLERS.get(path)
46
+ if (hit) return hit
47
+ const contours = pathOps.contours(path)
48
+ const entry = {
49
+ sampler: pathOps.sampler(path),
50
+ closed: contours.length === 1 && contours[0].closed,
51
+ }
52
+ if (SAMPLERS.size >= SAMPLER_CACHE) SAMPLERS.delete(SAMPLERS.keys().next().value!)
53
+ SAMPLERS.set(path, entry)
54
+ return entry
55
+ }
56
+
57
+ /**
58
+ * Items at even arc-length steps — by distance along the curve rather than the
59
+ * path's own parameter, which bunches them up wherever a curve is tight.
60
+ *
61
+ * On a closed path used whole, `n` items take `n` equal steps and `offset`
62
+ * wraps round, so an animated offset is a loop. Anywhere else the ends are
63
+ * ends: `n` items take `n - 1` steps so both get one, and an item moved past
64
+ * either is hidden rather than piled on it.
65
+ */
66
+ export function pathSlots(arrangement: PathArrangement, input: SlotInput): Slot[] {
67
+ const { count } = input
68
+ if (count === 0) return []
69
+ const { sampler, closed } = measured(arrangement.path)
70
+ const length = sampler.length
71
+ const a = clamp01(Math.min(arrangement.trimStart, arrangement.trimEnd))
72
+ const b = clamp01(Math.max(arrangement.trimStart, arrangement.trimEnd))
73
+ const start = a * length
74
+ const span = (b - a) * length
75
+ const loops = closed && a === 0 && b === 1
76
+ const offset = arrangement.offset + arrangement.flowSpeed * input.time
77
+ const place = arrangement.placement
78
+ const rad = (place.rotation * Math.PI) / 180
79
+ const cos = Math.cos(rad)
80
+ const sin = Math.sin(rad)
81
+
82
+ const slots: Slot[] = []
83
+ for (let k = 0; k < count; k++) {
84
+ let u: number
85
+ if (arrangement.spacing === "fixed" && span > 0) {
86
+ u = offset + (k * arrangement.spacingDistance) / span
87
+ } else if (loops) {
88
+ u = offset + k / count
89
+ } else {
90
+ u = offset + (count === 1 ? 0.5 : k / (count - 1))
91
+ }
92
+ let visible = true
93
+ if (loops) u = u - Math.floor(u)
94
+ else if (u < -1e-9 || u > 1 + 1e-9) visible = false
95
+ const at = sampler.frameAt(start + clamp01(u) * span)
96
+ if (!at) {
97
+ const s = slot(0, 0, { kind: "path", t: clamp01(u) })
98
+ s.opacity = 0
99
+ slots.push(s)
100
+ continue
101
+ }
102
+ // Into the grid's frame: about the path's origin, turned clockwise (y-up).
103
+ const flip = place.flipY ? -1 : 1
104
+ const px = (at.x - place.originX) * place.scale
105
+ const py = (at.y - place.originY) * place.scale * flip
106
+ const s = slot(
107
+ place.x + px * cos + py * sin,
108
+ place.y - px * sin + py * cos,
109
+ { kind: "path", t: clamp01(u) },
110
+ )
111
+ if (arrangement.alignToPath) s.rotation = (-Math.atan2(at.ty * flip, at.tx) * 180) / Math.PI + place.rotation
112
+ if (!visible) s.opacity = 0
113
+ slots.push(s)
114
+ }
115
+ return slots
116
+ }
117
+
118
+ function clamp01(v: number): number {
119
+ return v < 0 ? 0 : v > 1 ? 1 : v
120
+ }
@@ -1,62 +1,62 @@
1
- import { slot, type Slot, type SlotInput } from "./slot"
2
-
3
- export const RADIAL_DIRECTIONS = ["clockwise", "counterclockwise"] as const
4
- export type RadialDirection = (typeof RADIAL_DIRECTIONS)[number]
5
-
6
- export interface RadialArrangement {
7
- kind: "radial"
8
- radius: number
9
- /** Where the first item sits, degrees clockwise from twelve o'clock. */
10
- startAngle: number
11
- /** How much of the circle the items span, degrees. */
12
- sweep: number
13
- direction: RadialDirection
14
- /** Turn each item so its top faces away from the centre. */
15
- rotateItems: boolean
16
- /** Degrees per second the ring turns on its own. */
17
- spinSpeed: number
18
- }
19
-
20
- /**
21
- * The angle of each of `count` slots, clockwise from twelve o'clock. A full
22
- * circle divides by the count, so the last item does not land on the first; an
23
- * arc divides by one less, so both ends get an item. In between the divisor
24
- * slides from one to the other as the arc closes — steeply, so an arc is an arc
25
- * until it is nearly whole, and a sweep animated to 360 lands without a jump.
26
- */
27
- export function radialAngles(arrangement: RadialArrangement, count: number, time: number): number[] {
28
- const sweep = arrangement.sweep
29
- const closing = Math.min(1, Math.abs(sweep) / 360) ** 8
30
- const step = count <= 1 ? 0 : sweep / (count - 1 + closing)
31
- const sign = arrangement.direction === "counterclockwise" ? -1 : 1
32
- const start = arrangement.startAngle + arrangement.spinSpeed * time
33
- const angles: number[] = []
34
- for (let k = 0; k < count; k++) angles.push(start + sign * k * step)
35
- return angles
36
- }
37
-
38
- export function radialSlots(arrangement: RadialArrangement, input: SlotInput): Slot[] {
39
- const angles = radialAngles(arrangement, input.count, input.time)
40
- const start = angles[0] ?? 0
41
- return angles.map((angle) => {
42
- const rad = (angle * Math.PI) / 180
43
- const s = slot(arrangement.radius * Math.sin(rad), arrangement.radius * Math.cos(rad), {
44
- kind: "radial",
45
- angle: Math.abs(angle - start),
46
- })
47
- if (arrangement.rotateItems) s.rotation = angle
48
- return s
49
- })
50
- }
51
-
52
- /** The change to `startAngle` that brings slot `index` to twelve o'clock by the shorter way round. */
53
- export function radialTurnToTop(arrangement: RadialArrangement, count: number, index: number): number {
54
- const angle = radialAngles({ ...arrangement, spinSpeed: 0 }, count, 0)[index] ?? arrangement.startAngle
55
- return -wrapDegrees(angle)
56
- }
57
-
58
- /** `degrees` folded into `(-180, 180]`. */
59
- export function wrapDegrees(degrees: number): number {
60
- const wrapped = ((degrees % 360) + 360) % 360
61
- return wrapped > 180 ? wrapped - 360 : wrapped
62
- }
1
+ import { slot, type Slot, type SlotInput } from "./slot"
2
+
3
+ export const RADIAL_DIRECTIONS = ["clockwise", "counterclockwise"] as const
4
+ export type RadialDirection = (typeof RADIAL_DIRECTIONS)[number]
5
+
6
+ export interface RadialArrangement {
7
+ kind: "radial"
8
+ radius: number
9
+ /** Where the first item sits, degrees clockwise from twelve o'clock. */
10
+ startAngle: number
11
+ /** How much of the circle the items span, degrees. */
12
+ sweep: number
13
+ direction: RadialDirection
14
+ /** Turn each item so its top faces away from the centre. */
15
+ rotateItems: boolean
16
+ /** Degrees per second the ring turns on its own. */
17
+ spinSpeed: number
18
+ }
19
+
20
+ /**
21
+ * The angle of each of `count` slots, clockwise from twelve o'clock. A full
22
+ * circle divides by the count, so the last item does not land on the first; an
23
+ * arc divides by one less, so both ends get an item. In between the divisor
24
+ * slides from one to the other as the arc closes — steeply, so an arc is an arc
25
+ * until it is nearly whole, and a sweep animated to 360 lands without a jump.
26
+ */
27
+ export function radialAngles(arrangement: RadialArrangement, count: number, time: number): number[] {
28
+ const sweep = arrangement.sweep
29
+ const closing = Math.min(1, Math.abs(sweep) / 360) ** 8
30
+ const step = count <= 1 ? 0 : sweep / (count - 1 + closing)
31
+ const sign = arrangement.direction === "counterclockwise" ? -1 : 1
32
+ const start = arrangement.startAngle + arrangement.spinSpeed * time
33
+ const angles: number[] = []
34
+ for (let k = 0; k < count; k++) angles.push(start + sign * k * step)
35
+ return angles
36
+ }
37
+
38
+ export function radialSlots(arrangement: RadialArrangement, input: SlotInput): Slot[] {
39
+ const angles = radialAngles(arrangement, input.count, input.time)
40
+ const start = angles[0] ?? 0
41
+ return angles.map((angle) => {
42
+ const rad = (angle * Math.PI) / 180
43
+ const s = slot(arrangement.radius * Math.sin(rad), arrangement.radius * Math.cos(rad), {
44
+ kind: "radial",
45
+ angle: Math.abs(angle - start),
46
+ })
47
+ if (arrangement.rotateItems) s.rotation = angle
48
+ return s
49
+ })
50
+ }
51
+
52
+ /** The change to `startAngle` that brings slot `index` to twelve o'clock by the shorter way round. */
53
+ export function radialTurnToTop(arrangement: RadialArrangement, count: number, index: number): number {
54
+ const angle = radialAngles({ ...arrangement, spinSpeed: 0 }, count, 0)[index] ?? arrangement.startAngle
55
+ return -wrapDegrees(angle)
56
+ }
57
+
58
+ /** `degrees` folded into `(-180, 180]`. */
59
+ export function wrapDegrees(degrees: number): number {
60
+ const wrapped = ((degrees % 360) + 360) % 360
61
+ return wrapped > 180 ? wrapped - 360 : wrapped
62
+ }
@@ -1,108 +1,108 @@
1
- import type { Size2D } from "@motionscript/sdk"
2
-
3
- import { largest, slot, type Slot, type SlotInput } from "./slot"
4
-
5
- export const FILL_DIRECTIONS = ["row", "column"] as const
6
- export type FillDirection = (typeof FILL_DIRECTIONS)[number]
7
-
8
- /**
9
- * How big a cell is: the largest child (`fit`), a size of its own (`fixed`), or
10
- * an equal share of the grid's box (`fill`), which is also the room a child set
11
- * to `fill` grows into.
12
- */
13
- export const CELL_MODES = ["fit", "fixed", "fill"] as const
14
- export type CellMode = (typeof CELL_MODES)[number]
15
-
16
- /** Where a short last row (or column, filling by column) sits. */
17
- export const LINE_ALIGNS = ["start", "center", "end"] as const
18
- export type LineAlign = (typeof LINE_ALIGNS)[number]
19
-
20
- export interface RectangularArrangement {
21
- kind: "rectangular"
22
- columns: number
23
- fillDirection: FillDirection
24
- cellMode: CellMode
25
- cellWidth: number
26
- cellHeight: number
27
- columnGap: number
28
- rowGap: number
29
- lastLineAlign: LineAlign
30
- /** Share of a column's pitch every other row shifts right — `0.5` is a brick bond. */
31
- rowOffset: number
32
- /** Share of a row's pitch every other column shifts down. */
33
- columnOffset: number
34
- }
35
-
36
- /** Rows and columns `count` items make, `columns` wide. */
37
- export function rectangularShape(arrangement: RectangularArrangement, count: number): { rows: number; columns: number } {
38
- const columns = Math.max(1, Math.round(arrangement.columns))
39
- const rows = Math.max(1, Math.ceil(count / columns))
40
- // Filling by column, the rows are set by the count, and a column the items
41
- // never reach would pull the block off centre.
42
- if (arrangement.fillDirection === "column") return { rows, columns: Math.max(1, Math.ceil(count / rows)) }
43
- return { rows, columns }
44
- }
45
-
46
- /** The cell every item is laid out in — what a `fill` child is measured against. */
47
- export function rectangularCell(
48
- arrangement: RectangularArrangement,
49
- count: number,
50
- sizes: readonly Size2D[],
51
- box: Size2D,
52
- ): Size2D {
53
- const { rows, columns } = rectangularShape(arrangement, count)
54
- switch (arrangement.cellMode) {
55
- case "fixed":
56
- return { width: Math.max(0, arrangement.cellWidth), height: Math.max(0, arrangement.cellHeight) }
57
- case "fill":
58
- return {
59
- width: Math.max(0, (box.width - arrangement.columnGap * (columns - 1)) / columns),
60
- height: Math.max(0, (box.height - arrangement.rowGap * (rows - 1)) / rows),
61
- }
62
- default:
63
- return largest(sizes)
64
- }
65
- }
66
-
67
- /**
68
- * Items fill rows left to right, top to bottom (or columns top to bottom, left to
69
- * right), and the whole block is centred on the grid.
70
- */
71
- export function rectangularSlots(arrangement: RectangularArrangement, input: SlotInput): Slot[] {
72
- const { count } = input
73
- if (count === 0) return []
74
- const { rows, columns } = rectangularShape(arrangement, count)
75
- const byColumn = arrangement.fillDirection === "column"
76
- // Filling by column keeps the column count and lets the rows follow from it.
77
- const lines = byColumn ? columns : rows
78
- const perLine = byColumn ? rows : columns
79
- const cell = rectangularCell(arrangement, count, input.sizes, input.box)
80
- const pitchX = cell.width + arrangement.columnGap
81
- const pitchY = cell.height + arrangement.rowGap
82
- const lastLine = lines - 1
83
- const lastCount = count - lastLine * perLine
84
- const shortBy = perLine - lastCount
85
- const lead = arrangement.lastLineAlign === "center" ? shortBy / 2 : arrangement.lastLineAlign === "end" ? shortBy : 0
86
- // Shifting every other row by a share of a pitch moves the block's centre by
87
- // half that share; taking it back keeps the block centred on the grid.
88
- const shiftX = arrangement.rowOffset * pitchX
89
- const shiftY = arrangement.columnOffset * pitchY
90
-
91
- const slots: Slot[] = []
92
- for (let i = 0; i < count; i++) {
93
- const line = Math.floor(i / perLine)
94
- let along = i % perLine
95
- if (line === lastLine) along += lead
96
- const row = byColumn ? along : line
97
- const column = byColumn ? line : along
98
- // A centred short line sits between whole cells; its parity is the cell it covers most.
99
- const rowIndex = Math.round(row)
100
- const columnIndex = Math.round(column)
101
- const x = (column - (columns - 1) / 2) * pitchX
102
- + (rowIndex % 2 === 1 ? shiftX : 0) - (rows > 1 ? shiftX / 2 : 0)
103
- const y = ((rows - 1) / 2 - row) * pitchY
104
- - (columnIndex % 2 === 1 ? shiftY : 0) + (columns > 1 ? shiftY / 2 : 0)
105
- slots.push(slot(x, y, { kind: "rectangular", row: rowIndex, column: columnIndex }))
106
- }
107
- return slots
108
- }
1
+ import type { Size2D } from "@motionscript/sdk"
2
+
3
+ import { largest, slot, type Slot, type SlotInput } from "./slot"
4
+
5
+ export const FILL_DIRECTIONS = ["row", "column"] as const
6
+ export type FillDirection = (typeof FILL_DIRECTIONS)[number]
7
+
8
+ /**
9
+ * How big a cell is: the largest child (`fit`), a size of its own (`fixed`), or
10
+ * an equal share of the grid's box (`fill`), which is also the room a child set
11
+ * to `fill` grows into.
12
+ */
13
+ export const CELL_MODES = ["fit", "fixed", "fill"] as const
14
+ export type CellMode = (typeof CELL_MODES)[number]
15
+
16
+ /** Where a short last row (or column, filling by column) sits. */
17
+ export const LINE_ALIGNS = ["start", "center", "end"] as const
18
+ export type LineAlign = (typeof LINE_ALIGNS)[number]
19
+
20
+ export interface RectangularArrangement {
21
+ kind: "rectangular"
22
+ columns: number
23
+ fillDirection: FillDirection
24
+ cellMode: CellMode
25
+ cellWidth: number
26
+ cellHeight: number
27
+ columnGap: number
28
+ rowGap: number
29
+ lastLineAlign: LineAlign
30
+ /** Share of a column's pitch every other row shifts right — `0.5` is a brick bond. */
31
+ rowOffset: number
32
+ /** Share of a row's pitch every other column shifts down. */
33
+ columnOffset: number
34
+ }
35
+
36
+ /** Rows and columns `count` items make, `columns` wide. */
37
+ export function rectangularShape(arrangement: RectangularArrangement, count: number): { rows: number; columns: number } {
38
+ const columns = Math.max(1, Math.round(arrangement.columns))
39
+ const rows = Math.max(1, Math.ceil(count / columns))
40
+ // Filling by column, the rows are set by the count, and a column the items
41
+ // never reach would pull the block off centre.
42
+ if (arrangement.fillDirection === "column") return { rows, columns: Math.max(1, Math.ceil(count / rows)) }
43
+ return { rows, columns }
44
+ }
45
+
46
+ /** The cell every item is laid out in — what a `fill` child is measured against. */
47
+ export function rectangularCell(
48
+ arrangement: RectangularArrangement,
49
+ count: number,
50
+ sizes: readonly Size2D[],
51
+ box: Size2D,
52
+ ): Size2D {
53
+ const { rows, columns } = rectangularShape(arrangement, count)
54
+ switch (arrangement.cellMode) {
55
+ case "fixed":
56
+ return { width: Math.max(0, arrangement.cellWidth), height: Math.max(0, arrangement.cellHeight) }
57
+ case "fill":
58
+ return {
59
+ width: Math.max(0, (box.width - arrangement.columnGap * (columns - 1)) / columns),
60
+ height: Math.max(0, (box.height - arrangement.rowGap * (rows - 1)) / rows),
61
+ }
62
+ default:
63
+ return largest(sizes)
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Items fill rows left to right, top to bottom (or columns top to bottom, left to
69
+ * right), and the whole block is centred on the grid.
70
+ */
71
+ export function rectangularSlots(arrangement: RectangularArrangement, input: SlotInput): Slot[] {
72
+ const { count } = input
73
+ if (count === 0) return []
74
+ const { rows, columns } = rectangularShape(arrangement, count)
75
+ const byColumn = arrangement.fillDirection === "column"
76
+ // Filling by column keeps the column count and lets the rows follow from it.
77
+ const lines = byColumn ? columns : rows
78
+ const perLine = byColumn ? rows : columns
79
+ const cell = rectangularCell(arrangement, count, input.sizes, input.box)
80
+ const pitchX = cell.width + arrangement.columnGap
81
+ const pitchY = cell.height + arrangement.rowGap
82
+ const lastLine = lines - 1
83
+ const lastCount = count - lastLine * perLine
84
+ const shortBy = perLine - lastCount
85
+ const lead = arrangement.lastLineAlign === "center" ? shortBy / 2 : arrangement.lastLineAlign === "end" ? shortBy : 0
86
+ // Shifting every other row by a share of a pitch moves the block's centre by
87
+ // half that share; taking it back keeps the block centred on the grid.
88
+ const shiftX = arrangement.rowOffset * pitchX
89
+ const shiftY = arrangement.columnOffset * pitchY
90
+
91
+ const slots: Slot[] = []
92
+ for (let i = 0; i < count; i++) {
93
+ const line = Math.floor(i / perLine)
94
+ let along = i % perLine
95
+ if (line === lastLine) along += lead
96
+ const row = byColumn ? along : line
97
+ const column = byColumn ? line : along
98
+ // A centred short line sits between whole cells; its parity is the cell it covers most.
99
+ const rowIndex = Math.round(row)
100
+ const columnIndex = Math.round(column)
101
+ const x = (column - (columns - 1) / 2) * pitchX
102
+ + (rowIndex % 2 === 1 ? shiftX : 0) - (rows > 1 ? shiftX / 2 : 0)
103
+ const y = ((rows - 1) / 2 - row) * pitchY
104
+ - (columnIndex % 2 === 1 ? shiftY : 0) + (columns > 1 ? shiftY / 2 : 0)
105
+ slots.push(slot(x, y, { kind: "rectangular", row: rowIndex, column: columnIndex }))
106
+ }
107
+ return slots
108
+ }