From 401cc35743aafc4c8bdfb8055501650390bde88b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 29 Apr 2026 10:37:39 +0200 Subject: refactor: animate params over time --- .../src/params/__tests__/channel-layout.test.ts | 10 + apps/web/src/params/index.ts | 258 ++++++++++++++++++--- 2 files changed, 241 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/params/__tests__/channel-layout.test.ts (limited to 'apps/web/src/params') diff --git a/apps/web/src/params/__tests__/channel-layout.test.ts b/apps/web/src/params/__tests__/channel-layout.test.ts new file mode 100644 index 00000000..e058418d --- /dev/null +++ b/apps/web/src/params/__tests__/channel-layout.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import { formatLinearRgba } from "@/params"; + +describe("channel layout", () => { + test("formats linear animated colors as hex", () => { + expect(formatLinearRgba({ color: { r: 1, g: 0, b: 0, a: 1 } })).toBe( + "#ff0000", + ); + }); +}); diff --git a/apps/web/src/params/index.ts b/apps/web/src/params/index.ts index bb57a1c4..fa2550f8 100644 --- a/apps/web/src/params/index.ts +++ b/apps/web/src/params/index.ts @@ -1,10 +1,177 @@ -import { snapToStep } from "@/utils/math"; +import { converter, formatHex, formatHex8, parse } from "culori"; +import { clamp, snapToStep } from "@/utils/math"; export type ParamValue = number | string | boolean; export type ParamValues = Record; export type ParamGroup = "stroke"; +export type ChannelValueKind = "scalar" | "discrete"; +export type ChannelEasingMode = "independent" | "shared"; + +export interface LinearRgba { + r: number; + g: number; + b: number; + a: number; +} + +export interface ChannelComponentDefinition { + key: TKey; + valueKind: ChannelValueKind; + defaultInterpolation: "linear" | "hold"; +} + +export interface LeafChannelLayout { + kind: "leaf"; + component: ChannelComponentDefinition<"value">; + easingMode: "independent"; + decompose: (value: TValue) => { value: TValue }; + compose: (components: { value?: TValue }) => TValue | null; +} + +export interface CompositeChannelLayout< + TValue extends ParamValue = ParamValue, + TComponents extends object = Record, +> { + kind: "composite"; + components: Array>; + easingMode: ChannelEasingMode; + decompose: (value: TValue) => TComponents | null; + compose: (components: Partial) => TValue | null; +} + +export type ChannelLayout< + TValue extends ParamValue = ParamValue, + TComponents extends object = Record, +> = LeafChannelLayout | CompositeChannelLayout; + +export type ParamChannelLayout = + | LeafChannelLayout + | LeafChannelLayout + | LeafChannelLayout + | CompositeChannelLayout; + +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; +} + +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: number; + g: number; + b: number; + alpha: number; + } = { + 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 }), + }; + return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb); +} + +function createLeafChannelLayout({ + valueKind, + defaultInterpolation, +}: { + valueKind: ChannelValueKind; + defaultInterpolation: "linear" | "hold"; +}): LeafChannelLayout { + return { + kind: "leaf", + component: { + key: "value", + valueKind, + defaultInterpolation, + }, + easingMode: "independent", + decompose: (value) => ({ value }), + compose: ({ value }) => value ?? null, + }; +} + +export const NUMBER_CHANNEL_LAYOUT: LeafChannelLayout = + createLeafChannelLayout({ + valueKind: "scalar", + defaultInterpolation: "linear", + }); + +export const BOOLEAN_CHANNEL_LAYOUT: LeafChannelLayout = + createLeafChannelLayout({ + valueKind: "discrete", + defaultInterpolation: "hold", + }); + +export const STRING_CHANNEL_LAYOUT: LeafChannelLayout = + createLeafChannelLayout({ + valueKind: "discrete", + defaultInterpolation: "hold", + }); + +const colorComponent = ( + key: keyof LinearRgba, +): ChannelComponentDefinition => ({ + key, + valueKind: "scalar", + defaultInterpolation: "linear", +}); + +export const COLOR_CHANNEL_LAYOUT: CompositeChannelLayout = { + kind: "composite", + components: [ + colorComponent("r"), + colorComponent("g"), + colorComponent("b"), + colorComponent("a"), + ], + easingMode: "shared", + decompose: (value) => parseColorToLinearRgba({ color: value }), + compose: ({ r, g, b, a }) => + typeof r === "number" && + typeof g === "number" && + typeof b === "number" && + typeof a === "number" + ? formatLinearRgba({ color: { r, g, b, a } }) + : null, +}; + interface BaseParamDefinition { key: TKey; label: string; @@ -17,6 +184,7 @@ export interface NumberParamDefinition extends BaseParamDefinition { type: "number"; default: number; + channels?: LeafChannelLayout; min: number; max?: number; step: number; @@ -32,18 +200,21 @@ export interface BooleanParamDefinition extends BaseParamDefinition { type: "boolean"; default: boolean; + channels?: LeafChannelLayout; } export interface ColorParamDefinition extends BaseParamDefinition { type: "color"; default: string; + channels?: ChannelLayout; } export interface SelectParamDefinition extends BaseParamDefinition { type: "select"; default: string; + channels?: LeafChannelLayout; options: Array<{ value: string; label: string }>; } @@ -51,12 +222,14 @@ export interface TextParamDefinition extends BaseParamDefinition { type: "text"; default: string; + channels?: LeafChannelLayout; } export interface FontParamDefinition extends BaseParamDefinition { type: "font"; default: string; + channels?: LeafChannelLayout; } export type ParamDefinition = @@ -67,17 +240,41 @@ export type ParamDefinition = | TextParamDefinition | FontParamDefinition; +export function getParamChannelLayout({ + param, +}: { + param: ParamDefinition; +}): ParamChannelLayout { + switch (param.type) { + case "number": + return param.channels ?? NUMBER_CHANNEL_LAYOUT; + case "boolean": + return param.channels ?? BOOLEAN_CHANNEL_LAYOUT; + case "color": + return param.channels ?? COLOR_CHANNEL_LAYOUT; + case "select": + case "text": + case "font": + return param.channels ?? STRING_CHANNEL_LAYOUT; + default: { + const exhaustive: never = param; + return exhaustive; + } + } +} + export function getParamValueKind({ param, }: { param: ParamDefinition; }): "number" | "color" | "discrete" { - if (param.type === "number") { - return "number"; - } - if (param.type === "color") { + const layout = getParamChannelLayout({ param }); + if (layout.kind === "composite") { return "color"; } + if (layout.component.valueKind === "scalar") { + return "number"; + } return "discrete"; } @@ -86,7 +283,11 @@ export function getParamDefaultInterpolation({ }: { param: ParamDefinition; }): "linear" | "hold" { - return param.type === "number" || param.type === "color" ? "linear" : "hold"; + const layout = getParamChannelLayout({ param }); + if (layout.kind === "leaf") { + return layout.component.defaultInterpolation; + } + return layout.components[0]?.defaultInterpolation ?? "linear"; } export function getParamNumericRange({ @@ -112,27 +313,30 @@ export function coerceParamValue({ param: ParamDefinition; value: unknown; }): ParamValue | null { - if (param.type === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - const steppedValue = snapToStep({ value, step: param.step }); - const maxValue = param.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(param.min, steppedValue)); - } + switch (param.type) { + case "number": { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } - if (param.type === "boolean") { - return typeof value === "boolean" ? value : null; - } - - if (param.type === "color" || param.type === "text" || param.type === "font") { - return typeof value === "string" ? value : null; - } - - if (typeof value !== "string") { - return null; + const steppedValue = snapToStep({ value, step: param.step }); + const maxValue = param.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(param.min, steppedValue)); + } + case "boolean": + return typeof value === "boolean" ? value : null; + case "color": + case "text": + case "font": + return typeof value === "string" ? value : null; + case "select": + return typeof value === "string" && + param.options.some((option) => option.value === value) + ? value + : null; + default: { + const exhaustive: never = param; + return exhaustive; + } } - - return param.options.some((option) => option.value === value) ? value : null; } -- cgit v1.3.1