fix(core): per-property-group keyframe foundations (#1354)

Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.
This commit is contained in:
Miguel Ángel
2026-06-12 00:02:27 -04:00
committed by GitHub
parent 8802c3fdf8
commit 889e9f09ad
10 changed files with 848 additions and 100 deletions
@@ -1,41 +1,5 @@
{
"animations": [
{
"targetSelector": ".ambient-line",
"method": "from",
"position": 0.16,
"properties": {
"scaleX": 0,
"opacity": 0
},
"duration": 0.42,
"extras": {
"stagger": "__raw:0.08"
},
"id": ".ambient-line-from-160"
},
{
"targetSelector": ".ambient-word",
"method": "from",
"position": 0.08,
"properties": {
"scale": 0.92,
"opacity": 0
},
"duration": 0.5,
"id": ".ambient-word-from-80"
},
{
"targetSelector": ".headline .sub",
"method": "from",
"position": 0.2,
"properties": {
"y": 20,
"opacity": 0
},
"duration": 0.28,
"id": ".headline .sub-from-200"
},
{
"targetSelector": ".headline span",
"method": "from",
@@ -49,7 +13,50 @@
"extras": {
"stagger": "__raw:0.055"
},
"resolvedStart": 0.05,
"id": ".headline span-from-50"
},
{
"targetSelector": ".headline .sub",
"method": "from",
"position": 0.2,
"properties": {
"y": 20,
"opacity": 0
},
"duration": 0.28,
"ease": "power3.out",
"resolvedStart": 0.2,
"id": ".headline .sub-from-200"
},
{
"targetSelector": ".ambient-word",
"method": "from",
"position": 0.08,
"properties": {
"scale": 0.92,
"opacity": 0
},
"duration": 0.5,
"ease": "power3.out",
"resolvedStart": 0.08,
"id": ".ambient-word-from-80"
},
{
"targetSelector": ".ambient-line",
"method": "from",
"position": 0.16,
"properties": {
"scaleX": 0,
"opacity": 0
},
"duration": 0.42,
"ease": "power3.out",
"extras": {
"stagger": "__raw:0.08"
},
"resolvedStart": 0.16,
"id": ".ambient-line-from-160"
}
],
"timelineVar": "tl",
@@ -3,8 +3,8 @@
gsap.defaults({ force3D: true });
const tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: "power3.out" } });
tl.from(".headline span", { y: 46, opacity: 0, duration: 0.38, ease: "back.out(1.35)", stagger: 0.055 }, 0.05);
tl.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5 }, 0.08);
tl.from(".ambient-line", { scaleX: 0, opacity: 0, duration: 0.42, stagger: 0.08 }, 0.16);
tl.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28 }, 0.2);
tl.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5, ease: "power3.out" }, 0.08);
tl.from(".ambient-line", { scaleX: 0, opacity: 0, duration: 0.42, ease: "power3.out", stagger: 0.08 }, 0.16);
tl.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28, ease: "power3.out" }, 0.2);
window.__timelines["vpn-youtube-spot"] = tl;
@@ -14,6 +14,7 @@
},
"duration": 0.6,
"ease": "power3.out",
"resolvedStart": 0.1,
"id": "#hero-fromTo-100"
},
{
@@ -29,6 +30,7 @@
"opacity": 0
},
"duration": 0.45,
"resolvedStart": 0.5,
"id": "#caption-fromTo-500"
}
],
@@ -10,6 +10,7 @@
},
"duration": 0.5,
"ease": "power3.out",
"resolvedStart": 0.2,
"id": "#notification-to-200"
},
{
@@ -22,6 +23,7 @@
},
"duration": 0.3,
"ease": "power3.in",
"resolvedStart": 4.2,
"id": "#notification-to-4200"
}
],
@@ -10,6 +10,7 @@
},
"duration": 0.5,
"ease": "power3.out",
"resolvedStart": 0.1,
"id": "#card-to-100"
},
{
@@ -21,7 +22,9 @@
},
"duration": 0.15,
"ease": "power2.out",
"id": "#subscribe-btn-to-1000"
"propertyGroup": "scale",
"resolvedStart": 1,
"id": "#subscribe-btn-to-1000-scale"
},
{
"targetSelector": "#subscribe-btn",
@@ -32,7 +35,9 @@
},
"duration": 0.4,
"ease": "elastic.out(1, 0.4)",
"id": "#subscribe-btn-to-1150"
"propertyGroup": "scale",
"resolvedStart": 1.15,
"id": "#subscribe-btn-to-1150-scale"
},
{
"targetSelector": "#btn-subscribe",
@@ -43,7 +48,9 @@
},
"duration": 0.08,
"ease": "none",
"id": "#btn-subscribe-to-1150"
"propertyGroup": "visual",
"resolvedStart": 1.15,
"id": "#btn-subscribe-to-1150-visual"
},
{
"targetSelector": "#btn-subscribed",
@@ -54,7 +61,9 @@
},
"duration": 0.08,
"ease": "none",
"id": "#btn-subscribed-to-1180"
"propertyGroup": "visual",
"resolvedStart": 1.18,
"id": "#btn-subscribed-to-1180-visual"
},
{
"targetSelector": "#card",
@@ -66,6 +75,7 @@
},
"duration": 0.25,
"ease": "power3.in",
"resolvedStart": 3.8,
"id": "#card-to-3800"
}
],
@@ -45,6 +45,46 @@ export const SUPPORTED_PROPS = [
"innerText",
];
// ── Property Groups ─────────────────────────────────────────────────────────
// Each group maps to an independent GSAP tween so editing one property
// (e.g. drag → x/y) never contaminates another (e.g. scale, rotation).
export type PropertyGroupName = "position" | "scale" | "size" | "rotation" | "visual" | "other";
export const PROPERTY_GROUPS: Record<PropertyGroupName, ReadonlySet<string>> = {
position: new Set(["x", "y", "xPercent", "yPercent"]),
scale: new Set(["scale", "scaleX", "scaleY"]),
size: new Set(["width", "height"]),
rotation: new Set(["rotation", "skewX", "skewY"]),
visual: new Set(["opacity", "autoAlpha"]),
other: new Set<string>(),
};
const PROP_TO_GROUP = new Map<string, PropertyGroupName>();
for (const [group, props] of Object.entries(PROPERTY_GROUPS) as [
PropertyGroupName,
ReadonlySet<string>,
][]) {
for (const p of props) PROP_TO_GROUP.set(p, group);
}
export function classifyPropertyGroup(prop: string): PropertyGroupName {
return PROP_TO_GROUP.get(prop) ?? "other";
}
export function classifyTweenPropertyGroup(
properties: Record<string, unknown>,
): PropertyGroupName | undefined {
const groups = new Set<PropertyGroupName>();
for (const key of Object.keys(properties)) {
if (key === "transformOrigin") continue;
const g = classifyPropertyGroup(key);
groups.add(g);
}
if (groups.size === 1) return groups.values().next().value;
return undefined;
}
export const SUPPORTED_EASES = [
"none",
"power1.in",
@@ -658,9 +658,9 @@ describe("14. ID collision", () => {
const ids = result.animations.map((a) => a.id);
// All IDs must be unique
expect(new Set(ids).size).toBe(3);
expect(ids[0]).toBe("#el-to-0");
expect(ids[1]).toBe("#el-to-0-2");
expect(ids[2]).toBe("#el-to-0-3");
expect(ids[0]).toBe("#el-to-0-visual");
expect(ids[1]).toBe("#el-to-0-position");
expect(ids[2]).toBe("#el-to-0-position-2");
});
it("disambiguated IDs are stable across parses", () => {
@@ -932,7 +932,7 @@ describe("Additional edge cases", () => {
`;
const result = parseGsapScript(script);
// ID uses Math.round(position * 1000) for numeric positions
expect(result.animations[0].id).toBe("#el-to--2500");
expect(result.animations[0].id).toBe("#el-to--2500-position");
});
it("fromTo with no position arg defaults to 0", () => {
+341 -10
View File
@@ -18,8 +18,10 @@ import {
removeAllKeyframesFromScript,
addAnimationWithKeyframesToScript,
splitAnimationsInScript,
splitIntoPropertyGroups,
} from "./gsapParser.js";
import type { GsapAnimation } from "./gsapParser.js";
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js";
import type { Keyframe } from "../core.types";
import {
parseAndSerialize,
@@ -260,8 +262,8 @@ describe("parseGsapScript", () => {
expect(result1.animations[1].id).toBe(result2.animations[1].id);
// IDs encode selector, method, and position
expect(result1.animations[0].id).toBe("#el1-to-0");
expect(result1.animations[1].id).toBe("#el2-to-1000");
expect(result1.animations[0].id).toBe("#el1-to-0-visual");
expect(result1.animations[1].id).toBe("#el2-to-1000-position");
});
it("disambiguates colliding IDs with a suffix", () => {
@@ -272,8 +274,8 @@ describe("parseGsapScript", () => {
`;
const result = parseGsapScript(script);
expect(result.animations[0].id).toBe("#el1-to-0");
expect(result.animations[1].id).toBe("#el1-to-0-2");
expect(result.animations[0].id).toBe("#el1-to-0-visual");
expect(result.animations[1].id).toBe("#el1-to-0-visual-2");
});
it("uses string position in ID for relative positions", () => {
@@ -283,7 +285,219 @@ describe("parseGsapScript", () => {
`;
const result = parseGsapScript(script);
expect(result.animations[0].id).toBe("#el1-to-+=1");
expect(result.animations[0].id).toBe("#el1-to-+=1-visual");
});
});
describe("resolvedStart — timeline position resolution", () => {
it("resolves chained from() tweens with relative positions (sdk-test pattern)", () => {
const script = `
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
tl.from("#headline", { duration: 0.6, scale: 0.92, transformOrigin: "left center" })
.from("#subtext", { duration: 0.5, scale: 0.92, transformOrigin: "left center" }, "-=0.3")
.from("#box", { duration: 0.5, scale: 0.5, transformOrigin: "center center" }, "-=0.3");
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(3);
// Execution order: #headline, #subtext, #box
expect(result.animations[0].targetSelector).toBe("#headline");
expect(result.animations[1].targetSelector).toBe("#subtext");
expect(result.animations[2].targetSelector).toBe("#box");
// #headline: implicit position → starts at 0, ends at 0.6
expect(result.animations[0].resolvedStart).toBe(0);
expect(result.animations[0].implicitPosition).toBe(true);
// #subtext: "-=0.3" from cursor (0.6) → 0.6 - 0.3 = 0.3
expect(result.animations[1].resolvedStart).toBe(0.3);
// #box: "-=0.3" from cursor (max(0.6, 0.3+0.5=0.8) = 0.8) → 0.8 - 0.3 = 0.5
expect(result.animations[2].resolvedStart).toBe(0.5);
});
it("resolves += and < positions", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1");
tl.to("#el2", { x: 100, duration: 1 }, "<");
tl.to("#el3", { y: 50, duration: 0.3 }, "-=0.5");
`;
const result = parseGsapScript(script);
// #el1: "+=1" from cursor (0) → 0 + 1 = 1, ends at 1.5
expect(result.animations[0].resolvedStart).toBe(1);
// #el2: "<" = previous start → 1
expect(result.animations[1].resolvedStart).toBe(1);
// #el3: "-=0.5" from cursor (max(1.5, 1+1=2) = 2) → 2 - 0.5 = 1.5
expect(result.animations[2].resolvedStart).toBe(1.5);
});
it("resolves numeric positions directly", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
tl.to("#el2", { x: 100, duration: 1 }, 2);
`;
const result = parseGsapScript(script);
expect(result.animations[0].resolvedStart).toBe(0);
expect(result.animations[1].resolvedStart).toBe(2);
});
it("resolves implicit sequential positions", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 })
.to("#el2", { x: 100, duration: 1 })
.to("#el3", { y: 50, duration: 0.3 });
`;
const result = parseGsapScript(script);
// #el1: implicit → cursor=0, ends at 0.5
expect(result.animations[0].resolvedStart).toBe(0);
expect(result.animations[0].implicitPosition).toBe(true);
// #el2: implicit → cursor=0.5, ends at 1.5
expect(result.animations[1].resolvedStart).toBe(0.5);
expect(result.animations[1].implicitPosition).toBe(true);
// #el3: implicit → cursor=1.5, ends at 1.8
expect(result.animations[2].resolvedStart).toBe(1.5);
expect(result.animations[2].implicitPosition).toBe(true);
});
it("clamps negative resolvedStart to 0", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.2 });
tl.to("#el2", { x: 100, duration: 1 }, "-=5");
`;
const result = parseGsapScript(script);
expect(result.animations[1].resolvedStart).toBe(0);
});
it("uses GSAP default duration (0.5) for tweens with no explicit duration", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1 })
.to("#el2", { x: 100 });
`;
const result = parseGsapScript(script);
// #el1: starts at 0, duration defaults to 0.5 → cursor at 0.5
expect(result.animations[0].resolvedStart).toBe(0);
// #el2: starts at cursor = 0.5
expect(result.animations[1].resolvedStart).toBe(0.5);
});
it("treats set() as zero-duration", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.set("#el1", { opacity: 0 });
tl.to("#el2", { opacity: 1, duration: 1 });
`;
const result = parseGsapScript(script);
// set() at 0, zero duration → cursor stays at 0
expect(result.animations[0].resolvedStart).toBe(0);
// next tween starts at cursor = 0
expect(result.animations[1].resolvedStart).toBe(0);
});
});
describe("timeline defaults inheritance", () => {
it("inherits ease and duration from timeline defaults onto tweens", () => {
const script = `
const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } });
tl.from("#headline", { scale: 0.92, transformOrigin: "left center" })
.from("#subtext", { scale: 0.92 }, "-=0.3");
`;
const result = parseGsapScript(script);
expect(result.animations[0].ease).toBe("power3.out");
expect(result.animations[0].duration).toBe(0.6);
expect(result.animations[1].ease).toBe("power3.out");
expect(result.animations[1].duration).toBe(0.6);
});
it("does not override explicit ease/duration on individual tweens", () => {
const script = `
const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } });
tl.to("#el1", { opacity: 1, duration: 1, ease: "none" });
`;
const result = parseGsapScript(script);
expect(result.animations[0].ease).toBe("none");
expect(result.animations[0].duration).toBe(1);
});
it("uses inherited duration for position resolution", () => {
const script = `
const tl = gsap.timeline({ defaults: { duration: 0.8 } });
tl.from("#a", { scale: 0.5 })
.from("#b", { scale: 0.5 });
`;
const result = parseGsapScript(script);
// #a starts at 0, duration 0.8 → cursor at 0.8
expect(result.animations[0].resolvedStart).toBe(0);
// #b starts at cursor = 0.8
expect(result.animations[1].resolvedStart).toBe(0.8);
});
});
describe("property group classification", () => {
it("classifies individual properties into groups", () => {
expect(classifyPropertyGroup("x")).toBe("position");
expect(classifyPropertyGroup("y")).toBe("position");
expect(classifyPropertyGroup("xPercent")).toBe("position");
expect(classifyPropertyGroup("scale")).toBe("scale");
expect(classifyPropertyGroup("scaleX")).toBe("scale");
expect(classifyPropertyGroup("width")).toBe("size");
expect(classifyPropertyGroup("height")).toBe("size");
expect(classifyPropertyGroup("rotation")).toBe("rotation");
expect(classifyPropertyGroup("skewX")).toBe("rotation");
expect(classifyPropertyGroup("opacity")).toBe("visual");
expect(classifyPropertyGroup("autoAlpha")).toBe("visual");
expect(classifyPropertyGroup("borderRadius")).toBe("other");
expect(classifyPropertyGroup("fontSize")).toBe("other");
});
it("classifies a pure position tween", () => {
expect(classifyTweenPropertyGroup({ x: 100, y: 50 })).toBe("position");
});
it("classifies a pure scale tween", () => {
expect(classifyTweenPropertyGroup({ scale: 0.5 })).toBe("scale");
});
it("classifies scale + transformOrigin as scale (transformOrigin follows group)", () => {
expect(classifyTweenPropertyGroup({ scale: 0.5, transformOrigin: "center center" })).toBe(
"scale",
);
});
it("returns undefined for mixed-group tweens", () => {
expect(classifyTweenPropertyGroup({ x: 100, scale: 0.5 })).toBeUndefined();
expect(classifyTweenPropertyGroup({ x: 100, opacity: 0 })).toBeUndefined();
});
it("classifies tweens during parsing", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#a", { x: 100, y: 50, duration: 1 }, 0);
tl.to("#b", { scale: 0.5, duration: 0.5 }, 0);
tl.to("#c", { x: 100, scale: 0.5, opacity: 0, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].propertyGroup).toBe("position");
expect(result.animations[1].propertyGroup).toBe("scale");
expect(result.animations[2].propertyGroup).toBeUndefined();
});
});
@@ -1223,11 +1437,12 @@ describe("native GSAP keyframes parsing", () => {
const kfs = expectKeyframesFormat(anim, "object-array", 3);
// Total duration = 0.5 + 1 + 0.8 = 2.3
expectKeyframe(kfs[0], 0, { x: 0, opacity: 1 });
// Second: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22
expectKeyframe(kfs[1], 22, { x: 100 }, "power2.out");
// Third: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65
expectKeyframe(kfs[2], 65, { x: 200 });
// First: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22
expectKeyframe(kfs[0], 22, { x: 0, opacity: 1 });
// Second: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65
expectKeyframe(kfs[1], 65, { x: 100 }, "power2.out");
// Third: cumulative = 2.3, pct = round(2.3/2.3 * 100) = 100
expectKeyframe(kfs[2], 100, { x: 200 });
});
it("parses simple array keyframes format", () => {
@@ -1944,3 +2159,119 @@ tl.to("#el1", { y: 200, duration: 1 }, 3);`;
expect(result.skippedSelectors).toEqual([".el1"]);
});
});
describe("splitIntoPropertyGroups", () => {
const baseScript = `const tl = gsap.timeline({ paused: true });`;
it("splits flat to({x, y, scale, rotation}) into 3 group tweens", () => {
const script = `${baseScript}\ntl.to("#el", { x: 100, y: 50, scale: 1.5, rotation: 45, duration: 1 }, 0);`;
const parsed = parseGsapScript(script);
const animId = parsed.animations[0]!.id;
const result = splitIntoPropertyGroups(script, animId);
const reParsed = parseGsapScript(result.script);
// Should produce 3 tweens: position (x,y), scale, rotation
expect(reParsed.animations).toHaveLength(3);
expect(result.ids).toHaveLength(3);
const groups = new Set(reParsed.animations.map((a) => a.propertyGroup));
expect(groups.has("position")).toBe(true);
expect(groups.has("scale")).toBe(true);
expect(groups.has("rotation")).toBe(true);
const posAnim = reParsed.animations.find((a) => a.propertyGroup === "position")!;
expect(posAnim.properties.x).toBe(100);
expect(posAnim.properties.y).toBe(50);
expect(posAnim.properties.scale).toBeUndefined();
const scaleAnim = reParsed.animations.find((a) => a.propertyGroup === "scale")!;
expect(scaleAnim.properties.scale).toBe(1.5);
expect(scaleAnim.properties.x).toBeUndefined();
const rotAnim = reParsed.animations.find((a) => a.propertyGroup === "rotation")!;
expect(rotAnim.properties.rotation).toBe(45);
});
it("splits flat from({scale, opacity}) into 2 group tweens", () => {
const script = `${baseScript}\ntl.from("#el", { scale: 0.5, opacity: 0, duration: 0.5 }, 1);`;
const parsed = parseGsapScript(script);
const animId = parsed.animations[0]!.id;
const result = splitIntoPropertyGroups(script, animId);
const reParsed = parseGsapScript(result.script);
expect(reParsed.animations).toHaveLength(2);
expect(result.ids).toHaveLength(2);
const groups = new Set(reParsed.animations.map((a) => a.propertyGroup));
expect(groups.has("scale")).toBe(true);
expect(groups.has("visual")).toBe(true);
});
it("returns same ID for single-group tween (no split)", () => {
const script = `${baseScript}\ntl.to("#el", { x: 100, y: 50, duration: 1 }, 0);`;
const parsed = parseGsapScript(script);
const animId = parsed.animations[0]!.id;
const result = splitIntoPropertyGroups(script, animId);
expect(result.ids).toEqual([animId]);
// Script should be unchanged
const reParsed = parseGsapScript(result.script);
expect(reParsed.animations).toHaveLength(1);
});
it("preserves position, duration, ease on split tweens", () => {
const script = `${baseScript}\ntl.to("#el", { x: 100, scale: 2, duration: 0.8, ease: "power2.out" }, 1.5);`;
const parsed = parseGsapScript(script);
const animId = parsed.animations[0]!.id;
const result = splitIntoPropertyGroups(script, animId);
const reParsed = parseGsapScript(result.script);
expect(reParsed.animations).toHaveLength(2);
for (const anim of reParsed.animations) {
expect(anim.position).toBe(1.5);
expect(anim.duration).toBe(0.8);
expect(anim.ease).toBe("power2.out");
}
});
it("splits keyframed tween: each group gets only its properties per keyframe", () => {
const script = `${baseScript}\ntl.to("#el", { keyframes: { "0%": { x: 0, scale: 1 }, "50%": { x: 50, scale: 1.5 }, "100%": { x: 100, scale: 2 } }, duration: 2 }, 0);`;
const parsed = parseGsapScript(script);
const animId = parsed.animations[0]!.id;
const result = splitIntoPropertyGroups(script, animId);
const reParsed = parseGsapScript(result.script);
expect(reParsed.animations).toHaveLength(2);
expect(result.ids).toHaveLength(2);
// Both tweens are keyframed — identify them by the properties inside their keyframes.
const xAnim = reParsed.animations.find((a) =>
a.keyframes?.keyframes.some((kf) => "x" in kf.properties),
)!;
const scaleAnim = reParsed.animations.find((a) =>
a.keyframes?.keyframes.some((kf) => "scale" in kf.properties),
)!;
expect(xAnim).toBeDefined();
expect(xAnim.keyframes).toBeDefined();
expect(xAnim.keyframes!.keyframes).toHaveLength(3);
// Position keyframes should have x but not scale
for (const kf of xAnim.keyframes!.keyframes) {
expect(kf.properties.x).toBeDefined();
expect(kf.properties.scale).toBeUndefined();
}
expect(scaleAnim).toBeDefined();
expect(scaleAnim.keyframes).toBeDefined();
expect(scaleAnim.keyframes!.keyframes).toHaveLength(3);
// Scale keyframes should have scale but not x
for (const kf of scaleAnim.keyframes!.keyframes) {
expect(kf.properties.scale).toBeDefined();
expect(kf.properties.x).toBeUndefined();
}
});
});
+387 -41
View File
@@ -39,6 +39,14 @@ export {
SUPPORTED_PROPS,
SUPPORTED_EASES,
} from "./gsapSerialize";
export type { PropertyGroupName } from "./gsapConstants";
export {
PROPERTY_GROUPS,
classifyPropertyGroup,
classifyTweenPropertyGroup,
} from "./gsapConstants";
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants";
import type { PropertyGroupName } from "./gsapConstants";
export { generateSpringEaseData, SPRING_PRESETS } from "./springEase";
export type { SpringPreset } from "./springEase";
@@ -343,19 +351,47 @@ function isGsapTimelineCall(node: any): boolean {
);
}
interface TimelineDefaults {
ease?: string;
duration?: number;
}
interface TimelineDetection {
timelineVar: string | null;
timelineCount: number;
defaults?: TimelineDefaults;
}
function findTimelineVar(ast: any): TimelineDetection {
function extractTimelineDefaults(
callNode: any,
scope: ScopeBindings,
): TimelineDefaults | undefined {
const arg = callNode.arguments?.[0];
if (!arg || arg.type !== "ObjectExpression") return undefined;
const defaultsProp = arg.properties?.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "defaults",
);
if (!defaultsProp?.value || defaultsProp.value.type !== "ObjectExpression") return undefined;
const record = objectExpressionToRecord(defaultsProp.value, scope);
const result: TimelineDefaults = {};
if (typeof record.ease === "string") result.ease = record.ease;
if (typeof record.duration === "number") result.duration = record.duration;
return Object.keys(result).length > 0 ? result : undefined;
}
function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection {
let timelineVar: string | null = null;
let timelineCount = 0;
let defaults: TimelineDefaults | undefined;
const emptyScope: ScopeBindings = scope ?? new Map();
recast.types.visit(ast, {
visitVariableDeclarator(path: any) {
if (isGsapTimelineCall(path.node.init)) {
timelineCount += 1;
if (!timelineVar) timelineVar = path.node.id?.name ?? null;
if (!timelineVar) {
timelineVar = path.node.id?.name ?? null;
defaults = extractTimelineDefaults(path.node.init, emptyScope);
}
}
this.traverse(path);
},
@@ -365,12 +401,13 @@ function findTimelineVar(ast: any): TimelineDetection {
if (!timelineVar) {
const left = path.node.left;
if (left?.type === "Identifier") timelineVar = left.name;
defaults = extractTimelineDefaults(path.node.right, emptyScope);
}
}
this.traverse(path);
},
});
return { timelineVar, timelineCount };
return { timelineVar, timelineCount, defaults };
}
// ── Find All Tween Calls ────────────────────────────────────────────────────
@@ -632,13 +669,13 @@ function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyfram
if (totalDuration > 0) {
let cumulative = 0;
for (const entry of raw) {
cumulative += entry.duration ?? 0;
const percentage = Math.round((cumulative / totalDuration) * 100);
keyframes.push({
percentage,
properties: entry.properties,
...(entry.ease ? { ease: entry.ease } : {}),
});
cumulative += entry.duration ?? 0;
}
} else {
for (let i = 0; i < raw.length; i++) {
@@ -872,7 +909,8 @@ function tweenCallToAnimation(
}
}
const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0;
const hasPositionArg = !!call.positionArg;
const posVal = hasPositionArg ? extractLiteralValue(call.positionArg, scope) : 0;
const position: number | string =
typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0;
let duration = typeof vars.duration === "number" ? vars.duration : undefined;
@@ -891,6 +929,16 @@ function tweenCallToAnimation(
duration,
ease,
};
if (!hasPositionArg) anim.implicitPosition = true;
let group = classifyTweenPropertyGroup(properties);
if (!group && keyframesData) {
const kfProps: Record<string, unknown> = {};
for (const kf of keyframesData.keyframes) {
for (const k of Object.keys(kf.properties)) kfProps[k] = true;
}
group = classifyTweenPropertyGroup(kfProps);
}
if (group) anim.propertyGroup = group;
if (Object.keys(extras).length > 0) anim.extras = extras;
if (keyframesData) anim.keyframes = keyframesData;
if (motionPathResult) anim.arcPath = motionPathResult.arcPath;
@@ -899,8 +947,96 @@ function tweenCallToAnimation(
return anim;
}
// ── Timeline Position Resolution ──────────────────────────────────────────
const GSAP_DEFAULT_DURATION = 0.5;
// NOTE: Label-based positions (e.g. "myLabel+=0.5") are not yet resolved —
// they fall through to parseFloat which returns null for non-numeric strings.
function resolvePositionString(pos: string, cursor: number, prevStart: number): number | null {
const trimmed = pos.trim();
if (trimmed === "") return cursor;
if (trimmed.startsWith("+=")) {
const n = Number.parseFloat(trimmed.slice(2));
return Number.isFinite(n) ? cursor + n : null;
}
if (trimmed.startsWith("-=")) {
const n = Number.parseFloat(trimmed.slice(2));
return Number.isFinite(n) ? cursor - n : null;
}
if (trimmed === "<") return prevStart;
if (trimmed === ">") return cursor;
if (trimmed.startsWith("<")) {
const n = Number.parseFloat(trimmed.slice(1));
return Number.isFinite(n) ? prevStart + n : null;
}
if (trimmed.startsWith(">")) {
const n = Number.parseFloat(trimmed.slice(1));
return Number.isFinite(n) ? cursor + n : null;
}
const n = Number.parseFloat(trimmed);
return Number.isFinite(n) ? n : null;
}
function applyTimelineDefaults(
anims: Omit<GsapAnimation, "id">[],
defaults?: TimelineDefaults,
): void {
if (!defaults) return;
for (const anim of anims) {
if (anim.method === "set") continue;
if (anim.duration === undefined && defaults.duration !== undefined) {
anim.duration = defaults.duration;
}
if (anim.ease === undefined && defaults.ease !== undefined) {
anim.ease = defaults.ease;
}
}
}
function resolveTimelinePositions(anims: Omit<GsapAnimation, "id">[]): void {
let cursor = 0;
let prevStart = 0;
for (const anim of anims) {
const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION);
let start: number | null;
if (anim.implicitPosition) {
start = cursor;
} else if (typeof anim.position === "number") {
start = anim.position;
} else if (typeof anim.position === "string") {
start = resolvePositionString(anim.position, cursor, prevStart);
} else {
start = cursor;
}
if (start != null) {
anim.resolvedStart = Math.max(0, start);
prevStart = anim.resolvedStart;
cursor = Math.max(cursor, anim.resolvedStart + duration);
}
}
}
function sortBySourcePosition(calls: TweenCallInfo[]): void {
calls.sort((a, b) => {
const aLoc = a.node.callee?.property?.loc?.start;
const bLoc = b.node.callee?.property?.loc?.start;
if (!aLoc || !bLoc) return 0;
return aLoc.line - bLoc.line || aLoc.column - bLoc.column;
});
}
// ── Stable ID Generation ───────────────────────────────────────────────────
/**
* IDs are transient recomputed on every parse, never persisted across sessions.
* They exist only in ephemeral request/response payloads, React component state,
* and the in-memory keyframe cache (rebuilt on every page load). No database,
* localStorage, or file stores animation IDs, so changing the ID format (e.g.
* adding a `-scale`/`-position` suffix) is safe.
*/
function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
const counts = new Map<string, number>();
return anims.map((anim) => {
@@ -908,7 +1044,8 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
typeof anim.position === "number"
? String(Math.round(anim.position * 1000))
: String(anim.position);
const base = `${anim.targetSelector}-${anim.method}-${posKey}`;
const groupSuffix = anim.propertyGroup ? `-${anim.propertyGroup}` : "";
const base = `${anim.targetSelector}-${anim.method}-${posKey}${groupSuffix}`;
const count = (counts.get(base) ?? 0) + 1;
counts.set(base, count);
const id = count === 1 ? base : `${base}-${count}`;
@@ -937,10 +1074,14 @@ function parseGsapAst(script: string): ParsedGsapAst {
const ast = parseScript(script);
const scope = collectScopeBindings(ast);
const targetBindings = collectTargetBindings(ast, scope);
const detection = findTimelineVar(ast);
const detection = findTimelineVar(ast, scope);
const timelineVar = detection.timelineVar ?? "tl";
const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope)));
sortBySourcePosition(calls);
const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope));
applyTimelineDefaults(rawAnims, detection.defaults);
resolveTimelinePositions(rawAnims);
const animations = assignStableIds(rawAnims);
const located = animations.map((animation, i) => ({
id: animation.id,
call: calls[i]!,
@@ -1300,7 +1441,11 @@ export function removeAnimationFromScript(script: string, animationId: string):
console.warn("[gsap-parser] removeAnimationFromScript parse failed:", e);
return script;
}
const target = parsed.located.find((l) => l.id === animationId);
let target = parsed.located.find((l) => l.id === animationId);
if (!target) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
target = parsed.located.find((l) => l.id === convertedId);
}
if (!target) return script;
const node = target.call.node;
const stmtPath = findStatementPath(target.call.path);
@@ -1514,6 +1659,21 @@ function percentageFromKey(key: string): number {
return m ? Number.parseFloat(m[1]!) : Number.NaN;
}
const PCT_TOLERANCE = 2;
function findKeyframePropByPct(kfNode: any, percentage: number): { idx: number; prop: any } | null {
const props = kfNode.properties;
for (let i = 0; i < props.length; i++) {
if (!isObjectProperty(props[i])) continue;
const key = propKeyName(props[i]);
if (typeof key !== "string") continue;
const parsed = percentageFromKey(key);
if (Number.isNaN(parsed)) continue;
if (Math.abs(parsed - percentage) <= PCT_TOLERANCE) return { idx: i, prop: props[i] };
}
return null;
}
/** Build a keyframe value AST node from properties and optional ease. */
function buildKeyframeValueNode(properties: Record<string, number | string>, ease?: string): any {
const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
@@ -1571,7 +1731,11 @@ function collapseKeyframesToFlat(varsArg: any, record: Record<string, unknown>):
* updateKeyframeInScript.
*/
function locateKeyframeCtx(script: string, animationId: string, percentage: number) {
const loc = locateAnimation(script, animationId);
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
if (!loc) return null;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return null;
@@ -1613,12 +1777,20 @@ export function addKeyframeToScript(
const newValueNode = buildKeyframeValueNode(properties, ease);
// Replace if this percentage already exists
const existingIdx = kfNode.properties.findIndex(
(p: any) => isObjectProperty(p) && propKeyName(p) === pctKey,
);
if (existingIdx !== -1) {
kfNode.properties[existingIdx].value = newValueNode;
// Merge into existing keyframe at this percentage, or insert new
const existing = findKeyframePropByPct(kfNode, percentage);
if (existing) {
if (existing.prop.value?.type === "ObjectExpression") {
const existingRecord = objectExpressionToRecord(existing.prop.value, loc.parsed.scope);
const merged = { ...existingRecord };
for (const [k, v] of Object.entries(properties)) merged[k] = v;
existing.prop.value = buildKeyframeValueNode(
merged as Record<string, number | string>,
ease ?? (typeof existingRecord.ease === "string" ? existingRecord.ease : undefined),
);
} else {
existing.prop.value = newValueNode;
}
} else {
// Build the new property node with a quoted percentage key
const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0];
@@ -1703,12 +1875,11 @@ export function removeKeyframeFromScript(
): string {
const ctx = locateKeyframeCtx(script, animationId, percentage);
if (!ctx) return script;
const { loc, kfNode, pctKey } = ctx;
const { loc, kfNode } = ctx;
const removeIdx = kfNode.properties.findIndex(
(p: any) => isObjectProperty(p) && propKeyName(p) === pctKey,
);
if (removeIdx === -1) return script;
const match = findKeyframePropByPct(kfNode, percentage);
if (!match) return script;
const removeIdx = match.idx;
kfNode.properties.splice(removeIdx, 1);
@@ -1736,14 +1907,12 @@ export function updateKeyframeInScript(
): string {
const ctx = locateKeyframeCtx(script, animationId, percentage);
if (!ctx) return script;
const { loc, kfNode, pctKey } = ctx;
const { loc, kfNode } = ctx;
const existing = kfNode.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === pctKey,
);
if (!existing) return script;
const match = findKeyframePropByPct(kfNode, percentage);
if (!match) return script;
existing.value = buildKeyframeValueNode(properties, ease);
match.prop.value = buildKeyframeValueNode(properties, ease);
return recast.print(loc.parsed.ast).code;
}
@@ -1760,32 +1929,47 @@ function cssIdentityValue(prop: string): number {
return CSS_IDENTITY[prop] ?? 0;
}
/**
* Resolve the 0% (from) and 100% (to) property maps for a tween being
* converted to percentage keyframes.
*
* @param resolvedFromValues Despite the "from" in the name (historical), these
* are runtime-captured DOM values that override the conversion endpoint:
* - For to(): overrides fromProps (the 0% state / where the element is now).
* - For from(): overrides toProps (the 100% state / where the element rests).
* - For fromTo(): merges into toProps (the 100% endpoint the user is editing).
*/
function resolveConversionProps(
anim: GsapAnimation,
resolvedFromValues?: Record<string, number | string>,
): { fromProps: Record<string, number | string>; toProps: Record<string, number | string> } {
if (anim.method === "to") {
if (resolvedFromValues) {
return { fromProps: resolvedFromValues, toProps: { ...anim.properties } };
}
const identityFrom: Record<string, number | string> = {};
for (const [key, val] of Object.entries(anim.properties)) {
if (val != null) identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val;
}
return { fromProps: identityFrom, toProps: { ...anim.properties } };
const fromProps = resolvedFromValues
? { ...identityFrom, ...resolvedFromValues }
: identityFrom;
return { fromProps, toProps: { ...anim.properties } };
}
if (anim.method === "from") {
if (resolvedFromValues) {
return { fromProps: { ...anim.properties }, toProps: resolvedFromValues };
}
const identityTo: Record<string, number | string> = {};
for (const [key, val] of Object.entries(anim.properties)) {
if (val != null) identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val;
}
return { fromProps: { ...anim.properties }, toProps: identityTo };
const toProps = resolvedFromValues ? { ...identityTo, ...resolvedFromValues } : identityTo;
return { fromProps: { ...anim.properties }, toProps };
}
// fromTo
return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps: { ...anim.properties } };
// fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state),
// anim.properties = toVars (100% state). resolvedFromValues contains the
// current DOM position from a drag — it represents the NEW destination, so
// it merges into toProps (the 100% endpoint the user is editing), NOT into
// fromProps. This is intentional and not inverted.
const toProps = resolvedFromValues
? { ...anim.properties, ...resolvedFromValues }
: { ...anim.properties };
return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps };
}
/** Strip editable properties and ease/keyframes keys from a varsArg. */
@@ -1827,7 +2011,11 @@ export function convertToKeyframesInScript(
animationId: string,
resolvedFromValues?: Record<string, number | string>,
): string {
const loc = locateAnimation(script, animationId);
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
if (!loc) return script;
const anim = loc.target.animation;
@@ -1858,7 +2046,11 @@ export function convertToKeyframesInScript(
* last keyframe's properties.
*/
export function removeAllKeyframesFromScript(script: string, animationId: string): string {
const loc = locateAnimation(script, animationId);
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
if (!loc) return script;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
@@ -1894,7 +2086,11 @@ export function materializeKeyframesInScript(
easeEach?: string,
resolvedSelector?: string,
): string {
const loc = locateAnimation(script, animationId);
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
if (!loc) return script;
const varsArg = loc.target.call.varsArg;
@@ -2143,6 +2339,156 @@ export function removeArcPathFromScript(script: string, animationId: string): st
});
}
// ── Split Into Property Groups ────────────────────────────────────────────
/**
* Split a multi-group tween into separate per-group tweens. Each resulting
* tween contains only properties belonging to one property group (position,
* scale, rotation, visual, etc.). `transformOrigin` stays with the group that
* has the most properties. If the tween already belongs to a single group,
* returns the script unchanged with the original ID.
*/
// fallow-ignore-next-line complexity
export function splitIntoPropertyGroups(
script: string,
animationId: string,
): { script: string; ids: string[] } {
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
if (!loc) return { script, ids: [animationId] };
const anim = loc.target.animation;
// Collect the properties to partition. For keyframed tweens, gather the
// union of all properties across all keyframes. For flat tweens, use the
// tween's own properties map.
const allPropKeys = new Set<string>();
if (anim.keyframes) {
for (const kf of anim.keyframes.keyframes) {
for (const k of Object.keys(kf.properties)) allPropKeys.add(k);
}
} else {
for (const k of Object.keys(anim.properties)) allPropKeys.add(k);
}
// Partition properties into groups (excluding transformOrigin — handled below).
const groupProps = new Map<PropertyGroupName, string[]>();
for (const key of allPropKeys) {
if (key === "transformOrigin") continue;
const group = classifyPropertyGroup(key);
let arr = groupProps.get(group);
if (!arr) {
arr = [];
groupProps.set(group, arr);
}
arr.push(key);
}
// Only one group (or zero) — no split needed.
if (groupProps.size <= 1) return { script, ids: [anim.id] };
// Assign transformOrigin to the group with the most properties.
if (allPropKeys.has("transformOrigin")) {
let largestGroup: PropertyGroupName | undefined;
let largestCount = 0;
for (const [group, props] of groupProps) {
if (props.length > largestCount) {
largestCount = props.length;
largestGroup = group;
}
}
if (largestGroup) {
groupProps.get(largestGroup)!.push("transformOrigin");
}
}
// Build per-group tweens and insert them, then remove the original.
let result = script;
// Remove the original tween first.
result = removeAnimationFromScript(result, anim.id);
// Insert one tween per group. Iteration order of the Map follows insertion
// order, which mirrors the order properties were encountered.
for (const [, props] of groupProps) {
const propSet = new Set(props);
if (anim.keyframes) {
// Build keyframes containing only this group's properties per keyframe.
const groupKeyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}> = [];
for (const kf of anim.keyframes.keyframes) {
const filtered: Record<string, number | string> = {};
for (const [k, v] of Object.entries(kf.properties)) {
if (propSet.has(k)) filtered[k] = v;
}
// Skip keyframes where this group has zero properties.
if (Object.keys(filtered).length === 0) continue;
groupKeyframes.push({
percentage: kf.percentage,
properties: filtered,
...(kf.ease ? { ease: kf.ease } : {}),
});
}
if (groupKeyframes.length === 0) continue;
const addResult = addAnimationWithKeyframesToScript(
result,
anim.targetSelector,
typeof anim.position === "number" ? anim.position : 0,
anim.duration ?? 0.5,
groupKeyframes,
anim.keyframes.easeEach ?? anim.ease,
);
result = addResult.script;
} else {
// Flat tween — filter properties to this group.
const groupProperties: Record<string, number | string> = {};
for (const [k, v] of Object.entries(anim.properties)) {
if (propSet.has(k)) groupProperties[k] = v;
}
if (Object.keys(groupProperties).length === 0) continue;
let fromProperties: Record<string, number | string> | undefined;
if (anim.method === "fromTo" && anim.fromProperties) {
fromProperties = {};
for (const [k, v] of Object.entries(anim.fromProperties)) {
if (propSet.has(k)) fromProperties[k] = v;
}
}
const addResult = addAnimationToScript(result, {
targetSelector: anim.targetSelector,
method: anim.method,
position: anim.position,
duration: anim.duration,
ease: anim.ease,
properties: groupProperties,
fromProperties,
extras: anim.extras,
});
result = addResult.script;
}
}
// Re-parse to collect the new IDs.
const reParsed = parseGsapAst(result);
const newIds = reParsed.located
.filter((l) => l.animation.targetSelector === anim.targetSelector)
.map((l) => l.id);
return { script: result, ids: newIds };
}
/**
* Replace a dynamic loop that generates multiple tween calls with individual
* static `tl.to()` calls one per element. Finds the loop containing the
@@ -2213,7 +2559,7 @@ export function unrollDynamicAnimations(
kfEntries.push(`easeEach: ${JSON.stringify(el.easeEach)}`);
}
calls.push(
`tl.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`,
`${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`,
);
}
+12 -2
View File
@@ -7,6 +7,7 @@
* parsing of GSAP source lives in the Node-only `./gsapParser` module.
*/
import type { Keyframe, KeyframeProperties, ValidationResult } from "../core.types";
import type { PropertyGroupName } from "./gsapConstants";
export type GsapMethod = "set" | "to" | "from" | "fromTo";
@@ -29,6 +30,13 @@ export interface GsapAnimation {
hasUnresolvedKeyframes?: boolean;
/** True when the tween's target selector couldn't be statically resolved (dynamic). */
hasUnresolvedSelector?: boolean;
/** Absolute start time computed by walking the timeline chain (handles +=, -=, <, >, labels). */
resolvedStart?: number;
/** True when no position arg was authored — the tween is sequentially placed by GSAP. */
implicitPosition?: boolean;
/** Which property group this tween belongs to (position, scale, size, rotation, visual, other).
* Undefined for legacy mixed tweens that bundle multiple groups. */
propertyGroup?: PropertyGroupName;
}
export interface GsapPercentageKeyframe {
@@ -77,8 +85,10 @@ export function serializeGsapAnimations(
options?: { includeMediaSync?: boolean; preamble?: string; postamble?: string },
): string {
const sorted = [...animations].sort((a, b) => {
const aNum = typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER;
const bNum = typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER;
const aNum =
a.resolvedStart ?? (typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER);
const bNum =
b.resolvedStart ?? (typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER);
return aNum - bNum;
});
const lines = sorted.map((anim) => {