/** * Motion Gallery — pure catalog queries and preview strips. * The Deck and the preview server both read this; nothing here touches ctx.ui. */ import { MOTION_CATALOG } from "./catalog.ts"; import { brailleWakeGlyph, frameAt, framesOf, isBrailleGeometry, sweepMovingRight, sweepPhase, trailGlyph, } from "./frames.ts"; import { buildSweepCells } from "./sweep-cells.ts"; import type { MotionDef } from "./types.ts"; export const GALLERY_CATEGORIES = [ "wishcraft", "matrix", "procedural", "classic", "custom", ] as const; export type GalleryCategory = (typeof GALLERY_CATEGORIES)[number]; export function motionMatchesQuery(def: MotionDef, query: string): boolean { const q = query.trim().toLowerCase(); if (!q) return true; const hay = `${def.id} ${def.name} ${def.category} ${def.description} ${def.kind}`.toLowerCase(); return hay.includes(q); } export function filterMotions( query: string, catalog: readonly MotionDef[] = MOTION_CATALOG, ): MotionDef[] { return catalog.filter((def) => motionMatchesQuery(def, query)); } export function groupMotions( motions: readonly MotionDef[], ): Record { const groups: Record = { wishcraft: [], matrix: [], procedural: [], classic: [], custom: [], }; for (const def of motions) { groups[def.category].push(def); } return groups; } /** Travelling-head preview used by the gallery and composer. */ export function previewStrip( def: MotionDef, tick: number, width: number, ascii = false, ): string { const inner = Math.max(8, width); const head = frameAt(def, tick, ascii); const direction = def.generator?.direction ?? "forward"; const ease = def.generator?.ease ?? "linear"; const trail = def.generator?.trail ?? 2; // Same eased ping-pong phase as the status rail, so the gallery previews // the real traversal: decelerate, turn, glide back — no teleport wrap. // The shared cell builder keeps preview and rail geometry identical. const pos = sweepPhase(tick, inner, true, direction, ease); const movingRight = sweepMovingRight(tick, inner, direction); // Braille geometries preview their rippling sub-cell wake, matching the // status rail; the head cell keeps the motion's own frame glyph. const braille = !ascii && isBrailleGeometry(def); const cells = buildSweepCells({ width: inner, pos, movingRight, trailDepth: trail }); let out = ""; for (const cell of cells) { if (cell.kind === "head") out += head; else if (cell.kind === "trail") { out += braille ? brailleWakeGlyph(cell.index, pos, (1 - cell.distance / (trail + 1)) ** 2.2, 0) : trailGlyph(cell.step, ascii); } else out += ascii ? "-" : "─"; } return out; } export function motionIntervalMs(def: MotionDef): number { return def.generator?.intervalMs ?? 100; } export function motionFrameCount(def: MotionDef): number { return framesOf(def).length; } /** Toggle a motion id in a favorites list. Order of first add is kept. */ export function toggleFavorite(favorites: readonly string[], id: string): string[] { return favorites.includes(id) ? favorites.filter((item) => item !== id) : [...favorites, id]; }