From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/animation/binding-values.ts | 344 +++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 apps/web/src/animation/binding-values.ts (limited to 'apps/web/src/animation/binding-values.ts') diff --git a/apps/web/src/animation/binding-values.ts b/apps/web/src/animation/binding-values.ts new file mode 100644 index 00000000..a46aa9a6 --- /dev/null +++ b/apps/web/src/animation/binding-values.ts @@ -0,0 +1,344 @@ +import { converter, formatHex, formatHex8, parse } from "culori"; +import type { + AnimationBindingComponent, + AnimationBindingOfKind, + AnimationBindingInstance, + AnimationBindingKind, + ColorAnimationBinding, + DiscreteAnimationBinding, + NumberAnimationBinding, + AnimationPath, + AnimationValue, + DiscreteValue, + Vector2AnimationBinding, + VectorValue, +} from "@/animation/types"; +import { clamp } from "@/utils/math"; + +interface LinearRgba { + r: number; + g: number; + b: number; + a: number; +} + +export type AnimationComponentValue = number | DiscreteValue; + +const toRgb = converter("rgb"); + +function srgbToLinear({ value }: { value: number }): number { + return value <= 0.04045 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); +} + +function linearToSrgb({ value }: { value: number }): number { + const clamped = clamp({ value, min: 0, max: 1 }); + return clamped <= 0.0031308 + ? clamped * 12.92 + : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function isVectorValue(value: unknown): value is VectorValue { + return isRecord(value) && typeof value.x === "number" && typeof value.y === "number"; +} + +export type EasingMode = "independent" | "shared"; + +/** + * Declares how easing curves apply to a binding's components. + * "shared" means all components always use the same curve (e.g. color — you never + * want to ease R independently from G/B/A). "independent" means each component + * can have its own curve. + */ +export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode { + return kind === "color" ? "shared" : "independent"; +} + +export function getBindingComponentKeys({ + kind, +}: { + kind: AnimationBindingKind; +}): string[] { + if (kind === "vector2") { + return ["x", "y"]; + } + + if (kind === "color") { + return ["r", "g", "b", "a"]; + } + + return ["value"]; +} + +export function buildBindingChannelId({ + path, + componentKey, +}: { + path: AnimationPath; + componentKey: string; +}): string { + return `${path}:${componentKey}`; +} + +function createBindingComponent({ + path, + key, +}: { + path: AnimationPath; + key: TKey; +}): AnimationBindingComponent { + return { + key, + channelId: buildBindingChannelId({ path, componentKey: key }), + }; +} + +function cloneBindingComponents({ + components, +}: { + components: AnimationBindingComponent[]; +}): AnimationBindingComponent[] { + return components.map((component) => ({ ...component })); +} + +const animationBindingFactories = { + color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({ + path, + kind: "color", + colorSpace: "srgb-linear", + components: [ + createBindingComponent({ path, key: "r" }), + createBindingComponent({ path, key: "g" }), + createBindingComponent({ path, key: "b" }), + createBindingComponent({ path, key: "a" }), + ], + }), + vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({ + path, + kind: "vector2", + components: [ + createBindingComponent({ path, key: "x" }), + createBindingComponent({ path, key: "y" }), + ], + }), + number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({ + path, + kind: "number", + components: [createBindingComponent({ path, key: "value" })], + }), + discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({ + path, + kind: "discrete", + components: [createBindingComponent({ path, key: "value" })], + }), +} satisfies { + [K in AnimationBindingKind]: ({ + path, + }: { + path: AnimationPath; + }) => AnimationBindingOfKind; +}; + +export function createAnimationBinding({ + path, + kind, +}: { + path: AnimationPath; + kind: TKind; +}): AnimationBindingOfKind; +export function createAnimationBinding({ + path, + kind, +}: { + path: AnimationPath; + kind: AnimationBindingKind; +}): AnimationBindingInstance { + return animationBindingFactories[kind]({ path }); +} + +const animationBindingCloners = { + color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + vector2: ({ + binding, + }: { + binding: Vector2AnimationBinding; + }): Vector2AnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + number: ({ + binding, + }: { + binding: NumberAnimationBinding; + }): NumberAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + discrete: ({ + binding, + }: { + binding: DiscreteAnimationBinding; + }): DiscreteAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), +} satisfies { + [K in AnimationBindingKind]: ({ + binding, + }: { + binding: AnimationBindingOfKind; + }) => AnimationBindingOfKind; +}; + +export function cloneAnimationBinding({ + binding, +}: { + binding: AnimationBindingOfKind; +}): AnimationBindingOfKind; +export function cloneAnimationBinding({ + binding, +}: { + binding: AnimationBindingInstance; +}): AnimationBindingInstance { + switch (binding.kind) { + case "color": + return animationBindingCloners.color({ binding }); + case "vector2": + return animationBindingCloners.vector2({ binding }); + case "number": + return animationBindingCloners.number({ binding }); + case "discrete": + return animationBindingCloners.discrete({ binding }); + } +} + +export function parseColorToLinearRgba({ + color, +}: { + color: string; +}): LinearRgba | null { + const parsed = parse(color); + const rgb = parsed ? toRgb(parsed) : null; + if (!rgb) { + return null; + } + + return { + r: srgbToLinear({ value: rgb.r ?? 0 }), + g: srgbToLinear({ value: rgb.g ?? 0 }), + b: srgbToLinear({ value: rgb.b ?? 0 }), + a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }), + }; +} + +export function formatLinearRgba({ + color, +}: { + color: LinearRgba; +}): string { + const rgb = { + mode: "rgb", + r: linearToSrgb({ value: color.r }), + g: linearToSrgb({ value: color.g }), + b: linearToSrgb({ value: color.b }), + alpha: clamp({ value: color.a, min: 0, max: 1 }), + } as const; + return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb); +} + +export function decomposeAnimationValue({ + kind, + value, +}: { + kind: AnimationBindingKind; + value: AnimationValue; +}): Record | null { + if (kind === "number") { + return typeof value === "number" ? { value } : null; + } + + if (kind === "vector2") { + return isVectorValue(value) ? { x: value.x, y: value.y } : null; + } + + if (kind === "color") { + if (typeof value !== "string") { + return null; + } + const parsed = parseColorToLinearRgba({ color: value }); + if (!parsed) { + return null; + } + return { + r: parsed.r, + g: parsed.g, + b: parsed.b, + a: parsed.a, + }; + } + + return typeof value === "string" || typeof value === "boolean" + ? { value } + : null; +} + +export function composeAnimationValue({ + binding, + componentValues, +}: { + binding: AnimationBindingInstance; + componentValues: Record; +}): AnimationValue | null { + if (binding.kind === "number") { + const value = componentValues.value; + return typeof value === "number" ? value : null; + } + + if (binding.kind === "vector2") { + const x = componentValues.x; + const y = componentValues.y; + return typeof x === "number" && typeof y === "number" ? { x, y } : null; + } + + if (binding.kind === "color") { + const r = componentValues.r; + const g = componentValues.g; + const b = componentValues.b; + const a = componentValues.a; + if ( + typeof r !== "number" || + typeof g !== "number" || + typeof b !== "number" || + typeof a !== "number" + ) { + return null; + } + return formatLinearRgba({ + color: { + r, + g, + b, + a, + }, + }); + } + + const value = componentValues.value; + return typeof value === "string" || typeof value === "boolean" ? value : null; +} -- cgit v1.3.1