Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,5 @@
| `LM170` | reserved | Код зарезервирован параллельной spring-работой | Не использовать вне назначенного контракта | reserved |
| `LM171` | reserved | Код зарезервирован параллельной spring-работой | Не использовать вне назначенного контракта | reserved |
| `LM172` | compositor value error | Некорректный абсолютный бюджет ошибки реконструкции | Передать положительный конечный maxValueError в единицах numeric from/to | active |
| `LM173` | animate times | Некорректные times либо несовпадение authored-топологии | Передать неубывающие конечные times от нуля до единицы длиной в число стопов каждого канала | active |
| `LM174` | animate ease segments | Длина ease-массива не равна числу сегментов топологии | Передать по одной функции изинга на каждый сегмент | active |
76 changes: 65 additions & 11 deletions src/animate/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { MotionParamError } from '../errors.js';
import { interpolateColor } from '../value/color.js';
import type { ValueAST } from '../value/parse.js';
import { tryParseValue } from '../value/parse.js';
import { trackProgressAt, type TrackAt } from './track.js';
import {
interpolateUnit,
type ParsedRelative,
Expand Down Expand Up @@ -56,9 +57,13 @@ export interface NumericChannelSpec {
readonly _kind: 'num';
readonly _key: string;
readonly _group: GroupKey;
/** Явный from из пары [from, to]; undefined — резолв из реестра/стиля. */
/** Явный from из пары/кортежа; undefined — резолв из реестра/стиля. */
readonly _explicitFrom: number | undefined;
readonly _to: number;
/** N-keyframe трек (#205): все стопы (кортеж ≥3); undefined — pair/destination. */
readonly _stops?: readonly number[] | undefined;
/** Offsets трека: authored times либо равномерная сетка (фасад заполняет). */
_offsets?: readonly number[] | undefined;
}

/** CSS-канал (цвет/юниты через ./value): физика в прогресс-пространстве [0..1]. */
Expand All @@ -68,6 +73,9 @@ export interface CssChannelSpec {
readonly _group: GroupKey;
readonly _explicitFrom: ValueAST | undefined;
readonly _to: ValueAST;
/** N-keyframe трек (#205): все стопы (кортеж ≥3); undefined — pair/destination. */
readonly _stops?: readonly ValueAST[] | undefined;
_offsets?: readonly number[] | undefined;
}

export type ChannelSpec = NumericChannelSpec | CssChannelSpec;
Expand Down Expand Up @@ -108,35 +116,51 @@ export function parseProps(props: Record<string, unknown>): ChannelSpec[] {
if (key === 'transform') {
throw new MotionParamError('LM140');
}
const pair = Array.isArray(raw) ? raw : undefined;
if (pair && pair.length !== 2) {
// Snapshot массива ДО валидации (#205): hostile getters/мутации длины не
// могут изменить набор между проверкой и привязкой (та же дисциплина, что
// collectBoundedArrayLike для целей).
const tuple = Array.isArray(raw) ? [...(raw as unknown[])] : undefined;
if (tuple && tuple.length < 2) {
throw new MotionParamError('LM141');
}
// Кортеж ≥3 — N-keyframe трек (#205); ровно 2 — прежняя пара [from, to].
const stops = tuple && tuple.length > 2 ? tuple : undefined;
if (isTransformKey(key) || key === 'opacity') {
const group: GroupKey = key === 'opacity' ? 'opacity' : 'transform';
const explicitFrom = pair ? requireFinite(pair[0]) : undefined;
const to = requireFinite(pair ? pair[1] : raw);
const numericStops = stops?.map(requireFinite);
const explicitFrom = numericStops !== undefined
? numericStops[0]!
: tuple ? requireFinite(tuple[0]) : undefined;
const to = numericStops !== undefined
? numericStops[numericStops.length - 1]!
: requireFinite(tuple ? tuple[1] : raw);
// Full-движок хранит scale как две независимые физические оси. Равные
// значения всё равно сериализуются в компактный scale(N), зато переход
// uniform↔axial не меняет представление: обе позиции и pickup-скорость
// перехватываемого канала остаются явными.
if (key === 'scale') {
if (!keys.includes('scaleX')) {
specs.push({ _kind: 'num', _key: 'scaleX', _group: group, _explicitFrom: explicitFrom, _to: to });
specs.push({ _kind: 'num', _key: 'scaleX', _group: group, _explicitFrom: explicitFrom, _to: to, _stops: numericStops });
}
if (!keys.includes('scaleY')) {
specs.push({ _kind: 'num', _key: 'scaleY', _group: group, _explicitFrom: explicitFrom, _to: to });
specs.push({ _kind: 'num', _key: 'scaleY', _group: group, _explicitFrom: explicitFrom, _to: to, _stops: numericStops });
}
} else {
specs.push({ _kind: 'num', _key: key, _group: group, _explicitFrom: explicitFrom, _to: to });
specs.push({ _kind: 'num', _key: key, _group: group, _explicitFrom: explicitFrom, _to: to, _stops: numericStops });
}
} else {
const astStops = stops?.map(parseCssValue);
specs.push({
_kind: 'css',
_key: key,
_group: camelToKebab(key),
_explicitFrom: pair ? parseCssValue(pair[0]) : undefined,
_to: parseCssValue(pair ? pair[1] : raw),
_explicitFrom: astStops !== undefined
? astStops[0]!
: tuple ? parseCssValue(tuple[0]) : undefined,
_to: astStops !== undefined
? astStops[astStops.length - 1]!
: parseCssValue(tuple ? tuple[1] : raw),
_stops: astStops,
});
}
}
Expand All @@ -154,6 +178,9 @@ export interface NumericChannel {
readonly _solverTo: number;
/** Нормализованная скорость в представимом effect-space канала. */
readonly _v0: number;
/** N-keyframe трек (#205): стопы + offsets; undefined — 2-стоповый путь. */
readonly _stops?: readonly number[] | undefined;
readonly _offsets?: readonly number[] | undefined;
_value: number;
_velocity: number;
/** Последнее состояние, которое успешно прошло host write. */
Expand All @@ -166,6 +193,9 @@ export interface CssChannel {
readonly _key: string;
readonly _fromAst: ValueAST;
readonly _toAst: ValueAST;
/** N-keyframe трек (#205): AST-стопы + offsets; undefined — 2-стоповый путь. */
readonly _stopsAst?: readonly ValueAST[] | undefined;
readonly _offsets?: readonly number[] | undefined;
/**
* Стартовая скорость прогресса (прогресс/с). Явная пара [from, to] — 0
* (покой, канон числовых каналов); перехват живого рана — проекция ṗ̂
Expand Down Expand Up @@ -203,6 +233,8 @@ function numericChannel(
from: number,
to: number,
velocity: number,
stops?: readonly number[],
offsets?: readonly number[],
): NumericChannel {
const range = to - from;
const representableRange = Math.max(
Expand All @@ -224,6 +256,8 @@ function numericChannel(
// Seed принадлежит effect-space: IEEE-rounded current может не сохранять
// алгебраическую связь с исходным progress, особенно на соседних huge f64.
_v0: normalizeV0(velocity, solverTo - from),
_stops: stops,
_offsets: offsets,
_value: from,
_velocity: velocity,
_renderedValue: from,
Expand Down Expand Up @@ -476,6 +510,22 @@ export function cssAt(ch: CssChannel, p: number): string | number {
return interpolateParsed(ch._fromAst, ch._toAst, p);
}

/**
* Значение CSS-трека при глобальном k (сырое время/длительность, #205):
* выбор сегмента и easing — общий pure-модуль track.ts, интерполяция —
* тот же interpolateParsed, что у 2-стопового пути (кодек не копируется).
* `at` — переиспользуемый scratch вызывающего (ноль аллокаций на кадр).
*/
export function cssTrackAt(
ch: CssChannel,
k: number,
easeFor: (segment: number) => ((u: number) => number) | undefined,
at: TrackAt,
): string | number {
trackProgressAt(ch._offsets!, k, easeFor, at);
return interpolateParsed(ch._stopsAst![at._segment]!, ch._stopsAst![at._segment + 1]!, at._progress);
}

/**
* SSOT сериализации узкой numeric-поверхности. Вызов допустим только после
* доказанной topology: transform содержит ровно `x` без residual-каналов,
Expand Down Expand Up @@ -540,7 +590,9 @@ export function bindGroup(
from = Number.isFinite(read) ? read : 1; // opacity: дефолт браузера
}
}
numeric.push(numericChannel(spec._key, from, spec._to, velocity));
numeric.push(
numericChannel(spec._key, from, spec._to, velocity, spec._stops, spec._offsets),
);
} else {
let fromAst: ValueAST;
let v0 = 0;
Expand All @@ -560,6 +612,8 @@ export function bindGroup(
_key: spec._key,
_fromAst: fromAst,
_toAst: spec._to,
_stopsAst: spec._stops,
_offsets: spec._offsets,
_v0: v0,
_dpdt: v0, // производная на старте = засеянная (перехват до кадров — C¹)
_css: initialCss,
Expand Down
104 changes: 98 additions & 6 deletions src/animate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
type RequestFrameFn,
} from './main-unit.js';
import { surfaceBatchFor, type SurfaceBatch } from './surface-batch.js';
import { uniformOffsets } from './track.js';
import {
collectBoundedArrayLike,
requireAnimateOptions,
Expand All @@ -94,11 +95,15 @@ export type AnimateTarget =
| ArrayLike<AnimatableElement>
| readonly AnimatableElement[];

/** Значение канала: цель или пара [from, to] (явный from отключает подхват). */
/**
* Значение канала: цель, пара [from, to] (явный from отключает подхват) или
* N-keyframe кортеж длины ≥3 (#205): `x: [0, 120, -40, 0]` — все стопы явные,
* offsets равномерные либо options.times, изинг per-segment (options.ease).
*/
export type AnimatePropValue =
| number
| string
| readonly [number | string, number | string];
| readonly (number | string)[];

/** Каналы движения: transform-шортхенды, opacity, любые CSS-свойства. */
export type AnimateProps = Record<string, AnimatePropValue>;
Expand All @@ -109,8 +114,18 @@ export interface AnimateOptions {
readonly spring?: SpringParams | undefined;
/** Длительность tween (мс). Задана → режим tween (дефолт ease: standard). */
readonly duration?: number | undefined;
/** Изинг tween t∈[0,1]→прогресс. Задан без duration → duration.base. */
readonly ease?: ((t: number) => number) | undefined;
/**
* Изинг tween t∈[0,1]→прогресс. Задан без duration → duration.base.
* Массив (#205) — per-segment изинги N-keyframe вызова (длина N−1; все
* каналы вызова обязаны иметь одну authored-топологию N).
*/
readonly ease?: ((t: number) => number) | readonly ((t: number) => number)[] | undefined;
/**
* Offsets N-keyframe вызова (#205): длина N, конечные, неубывающие,
* times[0]=0, times[N−1]=1; дубликаты легальны (right-biased скачок).
* Без times offsets равномерные. Требует authored-топологию N у всех каналов.
*/
readonly times?: readonly number[] | undefined;
/** Задержка старта (мс, ≥ 0) — всем целям. */
readonly delay?: number | undefined;
/** Каскад для многих целей: число = gap (мс) или конфиг ./stagger. */
Expand Down Expand Up @@ -208,7 +223,10 @@ function resolveMode(options: AnimateOptions): MotionMode {
const durationInput = options.duration;
const easeInput = options.ease;
const hasSpring = input !== undefined;
const hasTween = durationInput !== undefined || easeInput !== undefined;
// times — грамматика keyframe-движка (#205): участвует в выборе режима,
// поэтому spring+times конфликтует тем же LM136, что spring+duration.
const hasTween =
durationInput !== undefined || easeInput !== undefined || options.times !== undefined;
if (hasSpring && hasTween) {
throw new MotionParamError('LM136');
}
Expand All @@ -217,6 +235,21 @@ function resolveMode(options: AnimateOptions): MotionMode {
if (!Number.isFinite(durationMs) || durationMs <= 0) {
throw new MotionParamError('LM137');
}
if (Array.isArray(easeInput)) {
// Snapshot массива изингов (#205): длина фиксируется до топологии,
// элементы обязаны быть функциями (тот же LM138, что scalar).
const eases = [...(easeInput as readonly unknown[])];
if (eases.length === 0) throw new MotionParamError('LM174');
for (const segmentEase of eases) {
if (typeof segmentEase !== 'function') throw new MotionParamError('LM138');
}
return {
_type: 'tween',
_durationMs: durationMs,
_ease: eases[0] as (t: number) => number,
_eases: eases as ((t: number) => number)[],
};
}
const ease = easeInput ?? STANDARD_EASING;
if (typeof ease !== 'function') {
throw new MotionParamError('LM138');
Expand All @@ -243,6 +276,64 @@ function resolveDelay(input: number | undefined): number {
return delay;
}

/**
* Контракт N-keyframe вызова (#205): валидация times (конечные, неубывающие,
* 0 → 1, дубликаты легальны), единая authored-топология N при times/ease[]
* (скрытых эвристик нет), синхронный отказ трек+явная пружина (LM136) и
* наполнение offsets треков (times либо равномерная сетка канала). Треки без
* явного режима получают tween с дефолтными длительностью и изингом —
* keyframe-грамматика принадлежит keyframe-движку.
*/
function resolveTracks(
specs: readonly ChannelSpec[],
mode: MotionMode,
options: AnimateOptions,
): MotionMode {
const timesInput = options.times;
let times: number[] | undefined;
if (timesInput !== undefined) {
if (!Array.isArray(timesInput)) throw new MotionParamError('LM173');
times = [...(timesInput as readonly unknown[])] as number[];
// Один проход: NaN/нечисло/убывание ловит !(t >= previous), диапазон — t > 1;
// цепочка от previous=0 гарантирует неотрицательность, края — точные 0 и 1.
let previous = 0;
for (const offset of times) {
if (typeof offset !== 'number' || !(offset >= previous) || offset > 1) {
throw new MotionParamError('LM173');
}
previous = offset;
}
if (times.length < 2 || times[0] !== 0 || previous !== 1) {
throw new MotionParamError('LM173');
}
}
const eases = mode._type === 'tween' ? mode._eases : undefined;
let hasTracks = false;
for (const spec of specs) if (spec._stops !== undefined) hasTracks = true;
if (!hasTracks && times === undefined && eases === undefined) return mode;
if (mode._type === 'spring') {
// options.spring задан явно (иначе times/eases уже выбрали бы tween).
if (options.spring !== undefined) throw new MotionParamError('LM136');
mode = { _type: 'tween', _durationMs: DEFAULT_DURATION_MS, _ease: STANDARD_EASING };
}
const topology = times !== undefined
? times.length
: eases !== undefined ? eases.length + 1 : 0;
if (topology !== 0 && eases !== undefined && eases.length !== topology - 1) {
throw new MotionParamError('LM174');
}
for (const spec of specs) {
const stops = spec._stops;
if (topology !== 0 && (stops?.length ?? (spec._explicitFrom !== undefined ? 2 : 1)) !== topology) {
// Код по источнику топологии: authored times либо длина ease-массива.
if (times !== undefined) throw new MotionParamError('LM173');
throw new MotionParamError('LM174');
}
if (stops !== undefined) spec._offsets = times ?? uniformOffsets(stops.length);
}
return mode;
}

// ─── Резолв целей (в момент вызова — SSR-safe импорт) ────────────────────────

function isElementLike(t: unknown): t is AnimatableElement {
Expand Down Expand Up @@ -358,11 +449,12 @@ export function animate(
// 1. Options — первая граница: остальные входы могут быть hostile getters.
options = requireAnimateOptions(options);
// Остальная валидация — вся ДО побочных эффектов (ноль записей при броске).
const mode = resolveMode(options);
let mode = resolveMode(options);
const baseDelay = resolveDelay(options.delay);
const staggerInput = options.stagger;
if (typeof staggerInput === 'number') resolveDelay(staggerInput);
const specs = parseProps(requireAnimateProps(props));
mode = resolveTracks(specs, mode, options);
const els = resolveTargets(target);
let targetDelays: number[] | undefined;
if (staggerInput !== undefined) {
Expand Down
Loading
Loading