/** * Motion helpers for the scroll films. All pure functions of a frame or a time, * the same ones the social media films use, so both move alike. */ export type Point = { readonly x: number; readonly y: number }; const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); /** 0 before `from`, 1 after `to`, linear in between. */ export const progress = (frame: number, from: number, to: number): number => clamp01((frame - from) / (to - from)); export const mix = (from: number, to: number, t: number): number => from + (to - from) * t; export const inOut = (t: number): number => t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; export const out = (t: number): number => 1 - (1 - t) ** 3; const easeInOut = (t: number): number => t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2; const round = (value: number): number => Math.round(value * 100) / 100; const wrap = (index: number, length: number): number => ((index % length) + length) % length; /** * Where something is that hops from stop to stop in an arc, round and round. * It rests on `stops[0]` at time 0 and reaches the next stop every * `framesPerHop`. The arc rises by `arcRatio` times the distance covered. */ export const hopAt = ( time: number, stops: readonly Point[], framesPerHop: number, arcRatio: number, ): Point => { const hop = Math.floor(time / framesPerHop); const from = stops[wrap(hop, stops.length)]; const to = stops[wrap(hop + 1, stops.length)]; if (!from || !to) return { x: 0, y: 0 }; const t = easeInOut(time / framesPerHop - hop); const distance = Math.hypot(to.x - from.x, to.y - from.y); // Rounded: sine and square root differ in the last digit between the server // and the browser, and the first frame is rendered by both. return { x: round(mix(from.x, to.x, t)), y: round( mix(from.y, to.y, t) - Math.sin(Math.PI * t) * distance * arcRatio, ), }; };