summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/animation/keyframe-query.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/animation/keyframe-query.ts')
-rw-r--r--apps/web/src/lib/animation/keyframe-query.ts370
1 files changed, 298 insertions, 72 deletions
diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts
index 62c8a171..304a6698 100644
--- a/apps/web/src/lib/animation/keyframe-query.ts
+++ b/apps/web/src/lib/animation/keyframe-query.ts
@@ -1,72 +1,298 @@
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
-import type {
- AnimationPath,
- ElementAnimations,
- ElementKeyframe,
-} from "@/lib/animation/types";
-import { isAnimationPath } from "./target-resolver";
-
-export function getElementKeyframes({
- animations,
-}: {
- animations: ElementAnimations | undefined;
-}): ElementKeyframe[] {
- if (!animations) {
- return [];
- }
-
- return Object.entries(animations.channels).flatMap(
- ([propertyPath, channel]) => {
- if (
- !channel ||
- channel.keyframes.length === 0 ||
- !isAnimationPath(propertyPath)
- ) {
- return [];
- }
-
- return channel.keyframes.map((keyframe) => ({
- propertyPath,
- id: keyframe.id,
- time: keyframe.time,
- value: keyframe.value,
- interpolation: keyframe.interpolation,
- }));
- },
- );
-}
-
-export function hasKeyframesForPath({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
-}): boolean {
- const channel = animations?.channels[propertyPath];
- return Boolean(channel && channel.keyframes.length > 0);
-}
-
-export function getKeyframeAtTime({
- animations,
- propertyPath,
- time,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
- time: number;
-}): ElementKeyframe | null {
- const channel = animations?.channels[propertyPath];
- if (!channel || channel.keyframes.length === 0) return null;
- const keyframe = channel.keyframes.find(
- (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
- );
- if (!keyframe) return null;
- return {
- propertyPath,
- id: keyframe.id,
- time: keyframe.time,
- value: keyframe.value,
- interpolation: keyframe.interpolation,
- };
-}
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import type {
+ AnimationBindingInstance,
+ AnimationChannel,
+ AnimationPath,
+ ElementAnimations,
+ ElementKeyframe,
+} from "@/lib/animation/types";
+import {
+ type AnimationComponentValue,
+ composeAnimationValue,
+} from "./binding-values";
+import {
+ getChannelValueAtTime,
+ getScalarSegmentInterpolation,
+} from "./interpolation";
+import { isAnimationPath } from "./target-resolver";
+
+function getBindingFallbackValue({
+ channel,
+}: {
+ channel: ElementAnimations["channels"][string];
+}) {
+ if (!channel || channel.keys.length === 0) {
+ return channel?.kind === "discrete" ? false : 0;
+ }
+
+ return channel.keys[0].value;
+}
+
+interface BindingKeyframeMatch {
+ componentIndex: number;
+ channel: AnimationChannel;
+ keyframe: AnimationChannel["keys"][number];
+}
+
+function getBindingKeyframeMatches({
+ animations,
+ binding,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+}): BindingKeyframeMatch[] {
+ return binding.components.flatMap((component, componentIndex) => {
+ const channel = animations.channels[component.channelId];
+ if (!channel || channel.keys.length === 0) {
+ return [];
+ }
+
+ return channel.keys.map((keyframe) => ({
+ componentIndex,
+ channel,
+ keyframe,
+ }));
+ });
+}
+
+function getUniqueBindingKeyframeMatches({
+ animations,
+ binding,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+}): BindingKeyframeMatch[] {
+ const sortedMatches = getBindingKeyframeMatches({
+ animations,
+ binding,
+ }).sort(
+ (leftMatch, rightMatch) =>
+ leftMatch.keyframe.time - rightMatch.keyframe.time ||
+ leftMatch.componentIndex - rightMatch.componentIndex,
+ );
+ const uniqueMatches: BindingKeyframeMatch[] = [];
+
+ for (const match of sortedMatches) {
+ const previousMatch = uniqueMatches[uniqueMatches.length - 1];
+ if (
+ !previousMatch ||
+ Math.abs(previousMatch.keyframe.time - match.keyframe.time) >
+ TIME_EPSILON_SECONDS
+ ) {
+ uniqueMatches.push(match);
+ continue;
+ }
+
+ if (
+ previousMatch.componentIndex !== 0 &&
+ match.componentIndex === 0
+ ) {
+ uniqueMatches[uniqueMatches.length - 1] = match;
+ }
+ }
+
+ return uniqueMatches;
+}
+
+function getPreferredBindingKeyframeMatch({
+ matches,
+}: {
+ matches: BindingKeyframeMatch[];
+}): BindingKeyframeMatch | null {
+ return (
+ matches.find((match) => match.componentIndex === 0) ??
+ matches[0] ??
+ null
+ );
+}
+
+function getComposedBindingValueAtTime({
+ animations,
+ binding,
+ time,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+ time: number;
+}) {
+ const componentValues = Object.fromEntries(
+ binding.components.map((component) => {
+ const channel = animations.channels[component.channelId];
+ return [
+ component.key,
+ getChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue: getBindingFallbackValue({ channel }),
+ }),
+ ];
+ }),
+ ) as Record<string, AnimationComponentValue | undefined>;
+
+ return composeAnimationValue({
+ binding,
+ componentValues,
+ });
+}
+
+function getKeyframeInterpolation({
+ channel,
+ keyframe,
+}: {
+ channel: AnimationChannel;
+ keyframe: AnimationChannel["keys"][number];
+}) {
+ return channel.kind === "scalar" && "segmentToNext" in keyframe
+ ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
+ : "hold";
+}
+
+function toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+ propertyPath: AnimationPath;
+ keyframeMatch: BindingKeyframeMatch;
+}): ElementKeyframe | null {
+ const value = getComposedBindingValueAtTime({
+ animations,
+ binding,
+ time: keyframeMatch.keyframe.time,
+ });
+ if (value === null) {
+ return null;
+ }
+
+ return {
+ propertyPath,
+ id: keyframeMatch.keyframe.id,
+ time: keyframeMatch.keyframe.time,
+ value,
+ interpolation: getKeyframeInterpolation({
+ channel: keyframeMatch.channel,
+ keyframe: keyframeMatch.keyframe,
+ }),
+ };
+}
+
+export function getElementKeyframes({
+ animations,
+}: {
+ animations: ElementAnimations | undefined;
+}): ElementKeyframe[] {
+ if (!animations) {
+ return [];
+ }
+
+ return Object.entries(animations.bindings).flatMap(
+ ([propertyPath, binding]) => {
+ if (!binding || !isAnimationPath(propertyPath)) {
+ return [];
+ }
+
+ return getUniqueBindingKeyframeMatches({
+ animations,
+ binding,
+ }).flatMap((keyframeMatch) => {
+ const keyframe = toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+ });
+ if (!keyframe) {
+ return [];
+ }
+
+ return [keyframe];
+ });
+ },
+ );
+}
+
+export function hasKeyframesForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+}): boolean {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return false;
+ }
+
+ return binding.components.some((component) =>
+ Boolean(animations?.channels[component.channelId]?.keys.length),
+ );
+}
+
+export function getKeyframeAtTime({
+ animations,
+ propertyPath,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ time: number;
+}): ElementKeyframe | null {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return null;
+ }
+
+ const keyframeMatch = getPreferredBindingKeyframeMatch({
+ matches: getBindingKeyframeMatches({
+ animations,
+ binding,
+ }).filter(({ keyframe }) =>
+ Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
+ ),
+ });
+ if (!keyframeMatch) {
+ return null;
+ }
+
+ return toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+ });
+}
+
+export function getKeyframeById({
+ animations,
+ propertyPath,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}): ElementKeyframe | null {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return null;
+ }
+
+ const keyframeMatch = getPreferredBindingKeyframeMatch({
+ matches: getBindingKeyframeMatches({
+ animations,
+ binding,
+ }).filter(({ keyframe }) => keyframe.id === keyframeId),
+ });
+ if (!keyframeMatch) {
+ return null;
+ }
+
+ return toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+ });
+}