@motionscript/grids 0.1.0-alpha.8 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/LICENSE +201 -201
- package/NOTICE +49 -49
- package/README.md +118 -118
- package/dist/browser/chunks/{chunk-J62D7EFZ.js → chunk-TYNFMZ3D.js} +1 -1
- package/dist/browser/chunks/chunk-TYNFMZ3D.js.map +7 -0
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/kit.js +1 -1
- package/dist/browser/manifest.json +1 -1
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +1 -1
- package/dist/engine.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/kit/index.js.map +1 -1
- package/dist/kit/item-effects.js.map +1 -1
- package/dist/kit/item-grid.js.map +1 -1
- package/dist/kit/order.js.map +1 -1
- package/dist/kit/random.js.map +1 -1
- package/dist/kit/slots/index.js.map +1 -1
- package/dist/kit/slots/path-templates.js.map +1 -1
- package/dist/kit/slots/path.js.map +1 -1
- package/dist/kit/slots/radial.js.map +1 -1
- package/dist/kit/slots/rectangular.js.map +1 -1
- package/dist/kit/slots/slot.js.map +1 -1
- package/dist/kit/slots/sphere.js.map +1 -1
- package/dist/kit/stagger.js.map +1 -1
- package/dist/nodes.js.map +1 -1
- package/dist/path-grid/index.js.map +1 -1
- package/dist/path-grid/path-grid.js.map +1 -1
- package/dist/radial-grid/index.js.map +1 -1
- package/dist/radial-grid/radial-grid.js.map +1 -1
- package/dist/rectangular-grid/index.js.map +1 -1
- package/dist/rectangular-grid/rectangular-grid.js.map +1 -1
- package/dist/sphere-grid/index.js.map +1 -1
- package/dist/sphere-grid/sphere-grid.js.map +1 -1
- package/package.json +4 -4
- package/registry.json +32 -32
- package/src/engine.ts +12 -12
- package/src/index.ts +6 -6
- package/src/kit/index.ts +18 -18
- package/src/kit/item-effects.ts +114 -114
- package/src/kit/item-grid.ts +648 -648
- package/src/kit/order.ts +41 -41
- package/src/kit/random.ts +29 -29
- package/src/kit/slots/index.ts +39 -39
- package/src/kit/slots/path-templates.ts +120 -120
- package/src/kit/slots/path.ts +120 -120
- package/src/kit/slots/radial.ts +62 -62
- package/src/kit/slots/rectangular.ts +108 -108
- package/src/kit/slots/slot.ts +82 -82
- package/src/kit/slots/sphere.ts +94 -94
- package/src/kit/stagger.ts +96 -96
- package/src/nodes.ts +30 -30
- package/src/path-grid/index.ts +1 -1
- package/src/path-grid/path-grid.ts +186 -186
- package/src/radial-grid/index.ts +1 -1
- package/src/radial-grid/radial-grid.ts +81 -81
- package/src/rectangular-grid/index.ts +1 -1
- package/src/rectangular-grid/rectangular-grid.ts +145 -145
- package/src/sphere-grid/index.ts +1 -1
- package/src/sphere-grid/sphere-grid.ts +129 -129
- package/dist/browser/chunks/chunk-J62D7EFZ.js.map +0 -7
package/src/kit/slots/path.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/kit/slots/radial.ts
CHANGED
|
@@ -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
|
+
}
|