diff --git a/packages/animations/src/animations.ts b/packages/animations/src/animations.ts index b0c07ad..00c92dc 100644 --- a/packages/animations/src/animations.ts +++ b/packages/animations/src/animations.ts @@ -1,7 +1,7 @@ import type { Fixture, Layout } from '@wavegrid/layout'; import { amberAnimations } from './amber'; -import { isArtGrid, prideColorAt, ROYGBIV, roygbivAt, setTarget, smooth } from './helpers'; +import { isArtGrid, prideColorAt, ROYGBIV, roygbivAt, setTarget, smooth, transColorAt } from './helpers'; import { AnimationFn, GridCell } from './types'; /** @@ -179,6 +179,35 @@ animations['pride-ring'] = (grid, tick, attack, layout) => { }); }; +// ── Trans flag animations ──── +// +// The same three shapes as the pride looks, on the trans palette — the +// sequences have always asked for these by name. + +animations['trans-flow'] = (grid, tick, attack, layout) => { + const speed = tick * 0.012; + layout.fixtures.forEach((f, i) => { + const color = transColorAt(f.v + speed); + setTarget(grid, i, color.h, color.s, 90, attack); + }); +}; + +animations['trans-breathe'] = (grid, tick, attack) => { + const color = transColorAt(tick * 0.008); + const brightness = 70 + Math.sin(tick * 0.04) * 20; + for (let i = 0; i < grid.length; i++) { + setTarget(grid, i, color.h, color.s, brightness, attack); + } +}; + +animations['trans-ring'] = (grid, tick, attack, layout) => { + const speed = tick * 0.012; + layout.fixtures.forEach((f, i) => { + const color = transColorAt(i / layout.count + speed); + setTarget(grid, i, color.h, color.s, 90, attack); + }); +}; + // ── Amber (Nova) animations ──── Object.assign(animations, amberAnimations); diff --git a/packages/animations/src/catalog.ts b/packages/animations/src/catalog.ts new file mode 100644 index 0000000..354afbd --- /dev/null +++ b/packages/animations/src/catalog.ts @@ -0,0 +1,163 @@ +// Browser-safe entry: this catalogue is read by the UI as well as the brain. +import { type Layout, presets } from '@wavegrid/layout/client'; + +import { animations } from './animations'; +import { scenes } from './scenes'; + +/** + * What a look needs from the rig it runs on. + * + * Most looks read normalized fixture geometry and work anywhere, so `any` is + * the default. The rest are honest about their requirement, because a bitmap + * drawn for a 7-wide grid is meaningless on a ring and an operator should be + * told that rather than discovering it on the lasers. + */ +export type Fits = + | { needs: 'any' } + /** Meaningful grid row/col, at least this big. */ + | { needs: 'grid'; cols: number; rows: number } + /** An ordered ring: fixtures the look can walk around a circle. */ + | { needs: 'ring' } + /** Written for specific installations, by layout id. */ + | { needs: 'layout'; ids: string[] }; + +export interface LookDef { + /** Wire name — the `name` field of an `animation`/`scene` command. */ + id: string; + kind: 'animation' | 'scene'; + label: string; + fits: Fits; +} + +/** Human-readable reason a look does not suit a layout, or '' when it does. */ +export function fitsReason(fits: Fits, layout: Layout): string { + switch (fits.needs) { + case 'any': + return ''; + case 'grid': + if (!layout.hasGridCoords) return 'needs a grid'; + return layout.cols >= fits.cols && layout.rows >= fits.rows + ? '' + : `needs ${fits.cols}×${fits.rows} or larger`; + case 'ring': + // Grids have no ring to travel around; every other topology does. + return layout.topology === 'grid' ? 'needs a ring' : ''; + case 'layout': + return fits.ids.includes(layout.id) ? '' : `made for ${fits.ids.join(', ')}`; + } +} + +export function fitsLayout(fits: Fits, layout: Layout): boolean { + return fitsReason(fits, layout) === ''; +} + +const ANY: Fits = { needs: 'any' }; +const RING: Fits = { needs: 'ring' }; +const ART_GRID: Fits = { needs: 'grid', cols: 7, rows: 7 }; + +/** + * Every look the brain can run, with what it needs. Kept beside the registries + * it describes, and a test fails if the two drift apart. + */ +export const LOOKS: LookDef[] = [ + // ── Animations that adapt to any rig ── + { id: 'wave', kind: 'animation', label: 'Wave', fits: ANY }, + { id: 'breathe', kind: 'animation', label: 'Breathe', fits: ANY }, + { id: 'rainbow', kind: 'animation', label: 'Rainbow', fits: ANY }, + { id: 'spiral', kind: 'animation', label: 'Spiral', fits: ANY }, + { id: 'rain', kind: 'animation', label: 'Rain', fits: ANY }, + { id: 'pacman', kind: 'animation', label: 'Pacman', fits: ANY }, + { id: 'pride-flow', kind: 'animation', label: 'Pride flow', fits: ANY }, + { id: 'pride-breathe', kind: 'animation', label: 'Pride breathe', fits: ANY }, + { id: 'pride-rotate', kind: 'animation', label: 'Pride rotate', fits: ANY }, + { id: 'pride-ring', kind: 'animation', label: 'Pride ring', fits: ANY }, + { id: 'trans-flow', kind: 'animation', label: 'Trans flow', fits: ANY }, + { id: 'trans-breathe', kind: 'animation', label: 'Trans breathe', fits: ANY }, + { id: 'trans-ring', kind: 'animation', label: 'Trans ring', fits: ANY }, + + // ── Bitmap art: drawn cell by cell for the 7-wide grid ── + { id: 'i-heart-sf', kind: 'animation', label: 'I ♥ SF', fits: ART_GRID }, + { id: 'heart-breathe', kind: 'animation', label: 'Heart breathe', fits: ART_GRID }, + + // ── Amber (Nova): brightness travelling around a circle ── + { id: 'amber-chase', kind: 'animation', label: 'Amber chase', fits: RING }, + { id: 'amber-comet', kind: 'animation', label: 'Amber comet', fits: RING }, + { id: 'amber-wave', kind: 'animation', label: 'Amber wave', fits: RING }, + { id: 'amber-levels', kind: 'animation', label: 'Amber levels', fits: RING }, + { id: 'amber-heartbeat', kind: 'animation', label: 'Amber heartbeat', fits: RING }, + { id: 'amber-embers', kind: 'animation', label: 'Amber embers', fits: RING }, + { id: 'amber-breathe', kind: 'animation', label: 'Amber breathe', fits: ANY }, + + // ── Scenes ── + { id: 'civic', kind: 'scene', label: 'Civic', fits: ANY }, + { id: 'pride', kind: 'scene', label: 'Pride', fits: ANY }, + { id: 'trans', kind: 'scene', label: 'Trans', fits: ANY }, + { id: 'gold', kind: 'scene', label: 'Gold', fits: ANY }, + { id: 'white', kind: 'scene', label: 'White', fits: ANY }, + { id: 'solstice', kind: 'scene', label: 'Solstice', fits: ANY }, + { id: 'ocean', kind: 'scene', label: 'Ocean', fits: ANY }, + { id: 'sunset', kind: 'scene', label: 'Sunset', fits: ANY }, + { id: 'forest', kind: 'scene', label: 'Forest', fits: ANY }, + { id: 'fire', kind: 'scene', label: 'Fire', fits: ANY }, + { id: 'off', kind: 'scene', label: 'Off', fits: ANY }, + { id: 'night', kind: 'scene', label: 'Night', fits: ANY }, + { id: 'checker', kind: 'scene', label: 'Checker', fits: ANY }, + { id: 'heart', kind: 'scene', label: 'Heart', fits: ART_GRID }, + { id: 'sf', kind: 'scene', label: 'SF', fits: ART_GRID }, + { id: 'amber', kind: 'scene', label: 'Amber', fits: ANY }, + { id: 'amber-glow', kind: 'scene', label: 'Amber glow', fits: ANY }, + { id: 'amber-alternate', kind: 'scene', label: 'Amber alternate', fits: ANY }, + { id: 'amber-ramp', kind: 'scene', label: 'Amber ramp', fits: RING }, + { id: 'amber-horizon', kind: 'scene', label: 'Amber horizon', fits: RING } +]; + +const BY_ID = new Map(LOOKS.map(l => [`${l.kind}:${l.id}`, l])); + +export function lookDef(kind: 'animation' | 'scene', id: string): LookDef | undefined { + return BY_ID.get(`${kind}:${id}`); +} + +/** Registry names with no catalog entry (or vice versa) — used by the drift test. */ +export function catalogDrift(): { uncatalogued: string[]; missing: string[] } { + const registered = [ + ...Object.keys(animations).map(id => `animation:${id}`), + ...Object.keys(scenes).map(id => `scene:${id}`) + ]; + return { + uncatalogued: registered.filter(key => !BY_ID.has(key)), + missing: [...BY_ID.keys()].filter(key => !registered.includes(key)) + }; +} + +/** + * The whole catalogue, ones that suit this rig first. Nothing is removed: an + * operator with a specific look in mind should see it and read why it is + * unavailable, rather than wonder where it went. + */ +export function looksForLayout(layout: Layout, kind?: 'animation' | 'scene'): Array { + return LOOKS + .filter(l => !kind || l.kind === kind) + .map(l => ({ ...l, reason: fitsReason(l.fits, layout) })) + .sort((a, b) => Number(!!a.reason) - Number(!!b.reason)); +} + +/** + * The rigs an operator builds shows for. Filtering is by *fit against a real + * layout*, not by a tag someone remembered to set — so "Nova" means "runs on + * the Nova ring", checked against the resolved preset. + */ +export interface LayoutFilter { + id: string; + label: string; + /** Layout this filter judges fit against; null for "All". */ + layout: Layout | null; +} + +export function layoutFilters(): LayoutFilter[] { + return [ + { id: 'all', label: 'All', layout: null }, + { id: 'grid-7x7', label: '7×7', layout: presets['grid-7x7']() }, + { id: 'grace-cathedral', label: 'Grace', layout: presets['grace-cathedral']() }, + { id: 'nova', label: 'Nova', layout: presets.nova() } + ]; +} diff --git a/packages/animations/src/helpers.ts b/packages/animations/src/helpers.ts index e633046..55fa7af 100644 --- a/packages/animations/src/helpers.ts +++ b/packages/animations/src/helpers.ts @@ -92,6 +92,31 @@ export function roygbivAt(position: number): { h: number; s: number } { }; } +/** The trans flag as a cyclic palette: blue, pink, white, pink, blue. */ +export const TRANS_COLORS: Array<{ h: number; s: number }> = [ + { h: 197, s: 100 }, + { h: 340, s: 60 }, + { h: 0, s: 0 }, + { h: 340, s: 60 }, + { h: 197, s: 100 } +]; + +/** Sample the trans palette at `position` (0–1, wrapping), blending stops. */ +export function transColorAt(position: number): { h: number; s: number } { + const scaled = wrapUnit(position) * TRANS_COLORS.length; + const idx = Math.floor(scaled); + const mix = scaled - idx; + const a = TRANS_COLORS[idx % TRANS_COLORS.length]; + const b = TRANS_COLORS[(idx + 1) % TRANS_COLORS.length]; + let dh = b.h - a.h; + if (dh > 180) dh -= 360; + if (dh < -180) dh += 360; + return { + h: (a.h + dh * mix + 360) % 360, + s: a.s + (b.s - a.s) * mix + }; +} + export function angleDelta(from: number, to: number): number { return ((to - from + 540) % 360) - 180; } diff --git a/packages/animations/src/index.ts b/packages/animations/src/index.ts index e2e931c..f714b5c 100644 --- a/packages/animations/src/index.ts +++ b/packages/animations/src/index.ts @@ -14,9 +14,27 @@ export { roygbivAt, setTarget, smooth, + TRANS_COLORS, + transColorAt, wrapUnit } from './helpers'; +// Catalogue: what each look needs from the rig it runs on +export type { Fits, LayoutFilter, LookDef } from './catalog'; +export { + catalogDrift, + fitsLayout, + fitsReason, + layoutFilters, + lookDef, + LOOKS, + looksForLayout +} from './catalog'; + +// Preset shows (sequences and playlist starting points) +export type { ShowPreset, ShowStep } from './shows'; +export { SHOW_PRESETS, showDuration, showFitsReason, showPresetsForLayout } from './shows'; + // Amber (Nova) looks export type { AmberLook } from './amber'; export { diff --git a/packages/animations/src/shows.ts b/packages/animations/src/shows.ts new file mode 100644 index 0000000..c42dbc6 --- /dev/null +++ b/packages/animations/src/shows.ts @@ -0,0 +1,399 @@ +import type { Layout } from '@wavegrid/layout/client'; + +import { fitsReason, lookDef } from './catalog'; + +export interface ShowStep { + type: 'animation' | 'scene' | 'evalPattern'; + name?: string; + code?: string; + /** Seconds on screen. */ + duration: number; +} + +export interface ShowPreset { + /** Stable id — what the UI keys and remembers. */ + id: string; + name: string; + description: string; + /** CSS gradient for the card's swatch. */ + gradient: string; + /** `sequence`: play as-is. `playlist`: a starting point to edit. */ + kind: 'sequence' | 'playlist'; + transition: 'cut' | 'fade'; + transitionDuration: number; + loop: boolean; + steps: ShowStep[]; +} + +const AMBER_GRADIENT = 'linear-gradient(135deg, #ffb703, #ff8800, #4a2a00)'; +const PRIDE_GRADIENT = 'linear-gradient(135deg, #e40303, #ff8c00, #ffed00, #008026, #004dff, #750787)'; + +/** + * Every preset show, for every rig. Nothing here is layout-specific by + * declaration: which rigs a preset suits is derived from the looks it plays + * (see `showFitsReason`), so a preset cannot claim a fit its steps don't have. + */ +export const SHOW_PRESETS: ShowPreset[] = [ + // ── Runs anywhere ── + { + id: 'solid-vibes', + name: 'Solid Vibes', + description: 'Pure colors, gradients, and static scenes — no movement, just beautiful light', + gradient: 'linear-gradient(135deg, #e40303, #ff8c00, #ffed00, #008026, #004dff, #c8a000)', + kind: 'sequence', + transition: 'fade', + transitionDuration: 3, + loop: true, + steps: [ + { type: 'scene', name: 'pride', duration: 150 }, + { type: 'scene', name: 'gold', duration: 120 }, + { type: 'scene', name: 'trans', duration: 150 }, + { type: 'scene', name: 'sunset', duration: 120 }, + { type: 'scene', name: 'ocean', duration: 120 }, + { type: 'scene', name: 'forest', duration: 120 }, + { type: 'scene', name: 'solstice', duration: 120 }, + { type: 'scene', name: 'fire', duration: 120 }, + { type: 'scene', name: 'night', duration: 120 } + ] + }, + { + id: 'pride-show', + name: 'Pride Show', + description: 'Static flags + flowing animations — alternating calm and motion', + gradient: PRIDE_GRADIENT, + kind: 'sequence', + transition: 'fade', + transitionDuration: 2, + loop: true, + steps: [ + { type: 'scene', name: 'pride', duration: 120 }, + { type: 'animation', name: 'pride-flow', duration: 180 }, + { type: 'animation', name: 'rainbow', duration: 120 }, + { type: 'animation', name: 'pride-ring', duration: 120 }, + { type: 'scene', name: 'pride', duration: 90 }, + { type: 'animation', name: 'pride-breathe', duration: 120 }, + { type: 'animation', name: 'pride-rotate', duration: 120 }, + { type: 'scene', name: 'pride', duration: 90 }, + { type: 'animation', name: 'rainbow', duration: 180 } + ] + }, + { + id: 'pride-trans', + name: 'Pride & Trans', + description: 'Mixed pride and trans — static flags, flowing animations, breathing colors', + gradient: 'linear-gradient(135deg, #e40303, #ff8c00, #5BCEFA, #F5A9B8, #750787)', + kind: 'sequence', + transition: 'fade', + transitionDuration: 2, + loop: true, + steps: [ + { type: 'scene', name: 'pride', duration: 120 }, + { type: 'animation', name: 'pride-flow', duration: 150 }, + { type: 'scene', name: 'trans', duration: 120 }, + { type: 'animation', name: 'trans-flow', duration: 150 }, + { type: 'animation', name: 'pride-ring', duration: 120 }, + { type: 'scene', name: 'trans', duration: 90 }, + { type: 'animation', name: 'trans-breathe', duration: 120 }, + { type: 'scene', name: 'pride', duration: 90 }, + { type: 'animation', name: 'pride-breathe', duration: 120 }, + { type: 'animation', name: 'trans-ring', duration: 120 }, + { type: 'scene', name: 'trans', duration: 90 }, + { type: 'animation', name: 'pride-rotate', duration: 120 }, + { type: 'animation', name: 'rainbow', duration: 120 }, + { type: 'animation', name: 'trans-flow', duration: 150 } + ] + }, + { + id: 'ambient', + name: 'Ambient', + description: 'Chill background — slow waves, breathing, rain', + gradient: 'linear-gradient(135deg, #1a3a5c, #2a5a3c, #3a2a5c)', + kind: 'sequence', + transition: 'fade', + transitionDuration: 4, + loop: true, + steps: [ + { type: 'animation', name: 'wave', duration: 300 }, + { type: 'animation', name: 'breathe', duration: 240 }, + { type: 'animation', name: 'rain', duration: 300 }, + { type: 'animation', name: 'spiral', duration: 240 }, + { type: 'animation', name: 'wave', duration: 300 } + ] + }, + { + id: 'high-energy', + name: 'High Energy', + description: 'Fast cuts — short bursts of variety', + gradient: 'linear-gradient(135deg, #ff4400, #ffcc00, #00ff88, #0088ff)', + kind: 'sequence', + transition: 'cut', + transitionDuration: 0, + loop: true, + steps: [ + { type: 'animation', name: 'pride-ring', duration: 60 }, + { type: 'animation', name: 'rainbow', duration: 45 }, + { type: 'animation', name: 'pride-rotate', duration: 60 }, + { type: 'animation', name: 'spiral', duration: 60 }, + { type: 'animation', name: 'wave', duration: 45 }, + { type: 'animation', name: 'pride-flow', duration: 60 }, + { type: 'animation', name: 'pacman', duration: 45 } + ] + }, + { + id: 'ambient-playlist', + name: 'Ambient', + description: 'Four slow looks, long holds — a starting point to edit', + gradient: 'linear-gradient(135deg, #1a3a5c, #2a5a3c, #3a2a5c)', + kind: 'playlist', + transition: 'fade', + transitionDuration: 3, + loop: true, + steps: [ + { type: 'animation', name: 'wave', duration: 180 }, + { type: 'animation', name: 'breathe', duration: 120 }, + { type: 'animation', name: 'rain', duration: 180 }, + { type: 'animation', name: 'spiral', duration: 120 } + ] + }, + { + id: 'pride-playlist', + name: 'Pride Show', + description: 'Flag looks back to back', + gradient: PRIDE_GRADIENT, + kind: 'playlist', + transition: 'fade', + transitionDuration: 2, + loop: true, + steps: [ + { type: 'animation', name: 'pride-flow', duration: 120 }, + { type: 'animation', name: 'pride-ring', duration: 120 }, + { type: 'animation', name: 'pride-breathe', duration: 60 }, + { type: 'animation', name: 'rainbow', duration: 120 }, + { type: 'animation', name: 'pride-rotate', duration: 60 } + ] + }, + + // ── 7×7 art grid ── + { + id: 'heart-night', + name: 'Heart Night', + description: 'Romantic vibes — hearts, breathing, and city love', + gradient: 'linear-gradient(135deg, #ff0040, #cc0030, #ff6080)', + kind: 'sequence', + transition: 'fade', + transitionDuration: 3, + loop: true, + steps: [ + { type: 'scene', name: 'heart', duration: 180 }, + { type: 'animation', name: 'heart-breathe', duration: 300 }, + { type: 'animation', name: 'i-heart-sf', duration: 180 }, + { type: 'animation', name: 'heart-breathe', duration: 300 }, + { type: 'scene', name: 'heart', duration: 120 } + ] + }, + { + id: 'sf-showcase', + name: 'SF Showcase', + description: 'City pride — SF scenes, hearts, and rainbows', + gradient: 'linear-gradient(135deg, #c8a000, #ff6060, #4060ff)', + kind: 'sequence', + transition: 'fade', + transitionDuration: 3, + loop: true, + steps: [ + { type: 'scene', name: 'sf', duration: 180 }, + { type: 'animation', name: 'i-heart-sf', duration: 240 }, + { type: 'animation', name: 'rainbow', duration: 120 }, + { type: 'scene', name: 'gold', duration: 120 }, + { type: 'animation', name: 'wave', duration: 180 }, + { type: 'scene', name: 'sf', duration: 120 } + ] + }, + { + id: 'sf-night-playlist', + name: 'SF Night', + description: 'Bitmap art for the 7-wide grid', + gradient: 'linear-gradient(135deg, #c8a000, #ff6060, #4060ff)', + kind: 'playlist', + transition: 'fade', + transitionDuration: 3, + loop: true, + steps: [ + { type: 'animation', name: 'i-heart-sf', duration: 180 }, + { type: 'animation', name: 'heart-breathe', duration: 120 }, + { type: 'scene', name: 'sf', duration: 60 }, + { type: 'animation', name: 'rainbow', duration: 120 } + ] + }, + + // ── Nova: amber on a ring ── + { + id: 'nova-amber-hour', + name: 'Nova Amber Hour', + description: 'The full amber vocabulary — still looks into motion, and back', + gradient: AMBER_GRADIENT, + kind: 'sequence', + transition: 'fade', + transitionDuration: 4, + loop: true, + steps: [ + { type: 'scene', name: 'amber-glow', duration: 120 }, + { type: 'animation', name: 'amber-breathe', duration: 180 }, + { type: 'animation', name: 'amber-wave', duration: 240 }, + { type: 'scene', name: 'amber-ramp', duration: 90 }, + { type: 'animation', name: 'amber-comet', duration: 180 }, + { type: 'animation', name: 'amber-levels', duration: 180 }, + { type: 'scene', name: 'amber-horizon', duration: 90 }, + { type: 'animation', name: 'amber-embers', duration: 240 }, + { type: 'scene', name: 'amber-glow', duration: 120 } + ] + }, + { + id: 'nova-slow-burn', + name: 'Nova Slow Burn', + description: 'Warm and barely moving — for when the room is the show', + gradient: 'linear-gradient(135deg, #6a3800, #ffb703, #3a1f00)', + kind: 'sequence', + transition: 'fade', + transitionDuration: 6, + loop: true, + steps: [ + { type: 'animation', name: 'amber-embers', duration: 420 }, + { type: 'scene', name: 'amber-glow', duration: 300 }, + { type: 'animation', name: 'amber-breathe', duration: 360 }, + { type: 'scene', name: 'amber-alternate', duration: 240 }, + { type: 'animation', name: 'amber-embers', duration: 420 } + ] + }, + { + id: 'nova-circle', + name: 'Nova Circle', + description: 'Motion that reads as a ring — chase, comet, wave, levels', + gradient: AMBER_GRADIENT, + kind: 'sequence', + transition: 'cut', + transitionDuration: 0, + loop: true, + steps: [ + { type: 'animation', name: 'amber-chase', duration: 90 }, + { type: 'animation', name: 'amber-comet', duration: 120 }, + { type: 'animation', name: 'amber-wave', duration: 120 }, + { type: 'animation', name: 'amber-levels', duration: 90 }, + { type: 'animation', name: 'amber-heartbeat', duration: 120 } + ] + }, + { + id: 'nova-house-lights', + name: 'Nova House Lights', + description: 'Lit, warm, clearly not a blackout — between cues', + gradient: 'linear-gradient(135deg, #ffb703, #7a4a00)', + kind: 'playlist', + transition: 'fade', + transitionDuration: 5, + loop: true, + steps: [ + { type: 'scene', name: 'amber-glow', duration: 300 }, + { type: 'animation', name: 'amber-embers', duration: 300 }, + { type: 'scene', name: 'amber', duration: 120 } + ] + }, + { + id: 'nova-chase-playlist', + name: 'Nova Chase', + description: 'Amber motion, short holds — a starting point to edit', + gradient: AMBER_GRADIENT, + kind: 'playlist', + transition: 'cut', + transitionDuration: 0, + loop: true, + steps: [ + { type: 'animation', name: 'amber-chase', duration: 60 }, + { type: 'animation', name: 'amber-comet', duration: 60 }, + { type: 'animation', name: 'amber-wave', duration: 60 }, + { type: 'animation', name: 'amber-levels', duration: 60 } + ] + }, + + // ── Grace Cathedral: two rings and a centre ── + { + id: 'grace-vigil', + name: 'Grace Vigil', + description: 'Candlelight for the rose window — amber and white, almost still', + gradient: 'linear-gradient(135deg, #ffb703, #ffffff, #6a3800)', + kind: 'sequence', + transition: 'fade', + transitionDuration: 6, + loop: true, + steps: [ + { type: 'scene', name: 'amber-glow', duration: 360 }, + { type: 'animation', name: 'amber-embers', duration: 420 }, + { type: 'scene', name: 'white', duration: 180 }, + { type: 'animation', name: 'amber-breathe', duration: 300 }, + { type: 'scene', name: 'gold', duration: 240 } + ] + }, + { + id: 'grace-rose', + name: 'Grace Rose', + description: 'Colour turning around the window — slow, wide sweeps', + gradient: 'linear-gradient(135deg, #ffb703, #c86400, #4a2a6a, #1a3a5c)', + kind: 'sequence', + transition: 'fade', + transitionDuration: 5, + loop: true, + steps: [ + { type: 'animation', name: 'amber-wave', duration: 300 }, + { type: 'animation', name: 'amber-comet', duration: 240 }, + { type: 'animation', name: 'spiral', duration: 300 }, + { type: 'animation', name: 'wave', duration: 240 }, + { type: 'scene', name: 'solstice', duration: 180 }, + { type: 'animation', name: 'amber-levels', duration: 240 } + ] + }, + { + id: 'grace-playlist', + name: 'Grace Warm', + description: 'Warm ring looks for the cathedral — a starting point to edit', + gradient: 'linear-gradient(135deg, #ffb703, #ffffff, #6a3800)', + kind: 'playlist', + transition: 'fade', + transitionDuration: 4, + loop: true, + steps: [ + { type: 'scene', name: 'amber-glow', duration: 240 }, + { type: 'animation', name: 'amber-wave', duration: 180 }, + { type: 'animation', name: 'amber-embers', duration: 240 }, + { type: 'scene', name: 'white', duration: 120 } + ] + } +]; + +export function showDuration(steps: ShowStep[]): number { + return steps.reduce((total, step) => total + step.duration, 0); +} + +/** + * Why a preset does not suit a layout, or '' when every step does. Custom code + * steps are the operator's business, so they never block a fit. + */ +export function showFitsReason(steps: ShowStep[], layout: Layout): string { + for (const step of steps) { + if (step.type === 'evalPattern' || !step.name) continue; + const def = lookDef(step.type, step.name); + if (!def) return `unknown look "${step.name}"`; + const reason = fitsReason(def.fits, layout); + if (reason) return `${def.label} ${reason}`; + } + return ''; +} + +/** Presets of one kind, the ones that suit this rig first. */ +export function showPresetsForLayout( + layout: Layout, + kind: ShowPreset['kind'] +): Array { + return SHOW_PRESETS + .filter(p => p.kind === kind) + .map(p => ({ ...p, reason: showFitsReason(p.steps, layout) })) + .sort((a, b) => Number(!!a.reason) - Number(!!b.reason)); +} diff --git a/packages/server/__tests__/catalog.test.ts b/packages/server/__tests__/catalog.test.ts new file mode 100644 index 0000000..2a10b65 --- /dev/null +++ b/packages/server/__tests__/catalog.test.ts @@ -0,0 +1,105 @@ +import { + animations, + catalogDrift, + fitsLayout, + fitsReason, + layoutFilters, + LOOKS, + looksForLayout, + scenes, + SHOW_PRESETS, + showFitsReason, + showPresetsForLayout +} from '@wavegrid/animations'; +import { presets } from '@wavegrid/layout'; + +const grid7x7 = presets['grid-7x7'](); +const nova = presets.nova(); +const grace = presets['grace-cathedral'](); + +describe('catalog', () => { + it('describes every registered animation and scene, and nothing else', () => { + expect(catalogDrift()).toEqual({ uncatalogued: [], missing: [] }); + }); + + it('has one entry per look', () => { + const keys = LOOKS.map(l => `${l.kind}:${l.id}`); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('keeps bitmap art off rigs that have no 7-wide grid', () => { + const heart = LOOKS.find(l => l.id === 'i-heart-sf')!; + expect(fitsLayout(heart.fits, grid7x7)).toBe(true); + expect(fitsLayout(heart.fits, nova)).toBe(false); + expect(fitsReason(heart.fits, nova)).toBe('needs a grid'); + }); + + it('allows ring looks on any ring-ish layout but not on a grid', () => { + const chase = LOOKS.find(l => l.id === 'amber-chase')!; + expect(fitsLayout(chase.fits, nova)).toBe(true); + expect(fitsLayout(chase.fits, grace)).toBe(true); + expect(fitsReason(chase.fits, grid7x7)).toBe('needs a ring'); + }); + + it('lists everything for a layout, fitting looks first', () => { + const looks = looksForLayout(nova); + expect(looks).toHaveLength(LOOKS.length); + const firstUnfit = looks.findIndex(l => l.reason !== ''); + expect(firstUnfit).toBeGreaterThan(0); + expect(looks.slice(firstUnfit).every(l => l.reason !== '')).toBe(true); + }); + + it('offers a filter per rig an operator builds shows for', () => { + const filters = layoutFilters(); + expect(filters.map(f => f.id)).toEqual(['all', 'grid-7x7', 'grace-cathedral', 'nova']); + expect(filters[0].layout).toBeNull(); + expect(filters[3].layout?.count).toBe(6); + }); +}); + +describe('show presets', () => { + it('only plays looks that exist', () => { + for (const preset of SHOW_PRESETS) { + for (const step of preset.steps) { + if (step.type === 'animation') expect(animations[step.name!]).toBeDefined(); + if (step.type === 'scene') expect(scenes[step.name!]).toBeDefined(); + } + } + }); + + it('has unique ids', () => { + const ids = SHOW_PRESETS.map(p => p.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('reports the first step that does not suit the rig', () => { + const heartNight = SHOW_PRESETS.find(p => p.id === 'heart-night')!; + expect(showFitsReason(heartNight.steps, grid7x7)).toBe(''); + expect(showFitsReason(heartNight.steps, nova)).toBe('Heart needs a grid'); + }); + + it('gives every rig fitting sequences and playlists', () => { + for (const layout of [grid7x7, nova, grace]) { + for (const kind of ['sequence', 'playlist'] as const) { + const fitting = showPresetsForLayout(layout, kind).filter(p => p.reason === ''); + expect(fitting.length).toBeGreaterThan(0); + } + } + }); + + it('ships Nova amber shows that run on the Nova ring', () => { + const novaShows = SHOW_PRESETS.filter(p => p.id.startsWith('nova-')); + expect(novaShows.length).toBeGreaterThanOrEqual(4); + for (const show of novaShows) { + expect(showFitsReason(show.steps, nova)).toBe(''); + } + }); + + it('ships Grace shows that run on the cathedral layout', () => { + const graceShows = SHOW_PRESETS.filter(p => p.id.startsWith('grace-')); + expect(graceShows.length).toBeGreaterThanOrEqual(3); + for (const show of graceShows) { + expect(showFitsReason(show.steps, grace)).toBe(''); + } + }); +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index c3eebac..09b1241 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@wavegrid/ui", "version": "1.2.0", "type": "module", - "description": "Wavegrid control UI — a static Vite SPA served by @wavegrid/server on one origin", + "description": "Wavegrid control UI \u2014 a static Vite SPA served by @wavegrid/server on one origin", "license": "SEE LICENSE IN LICENSE", "publishConfig": { "access": "public" @@ -18,6 +18,7 @@ "test": "jest --passWithNoTests" }, "dependencies": { + "@wavegrid/animations": "workspace:*", "@wavegrid/layout": "workspace:*", "react": "^19.1.0", "react-dom": "^19.1.0" diff --git a/packages/ui/src/app.tsx b/packages/ui/src/app.tsx index eb137be..42298af 100644 --- a/packages/ui/src/app.tsx +++ b/packages/ui/src/app.tsx @@ -1,3 +1,4 @@ +import { type Layout, presets } from '@wavegrid/layout/client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { AudioTab } from '@/components/audio-tab'; @@ -31,6 +32,9 @@ import { useConfig } from '@/lib/use-config'; import { useIsPhone } from '@/lib/use-media-query'; import { type PlaylistState, useSocket } from '@/lib/use-socket'; +// Stands in only until /api/config answers, and matches useConfig's own fallback. +const FALLBACK_LAYOUT = presets['grid-7x7'](); + type PanelLayout = 'bottom' | 'right'; type TrailFadeEntry = { h: number; @@ -73,7 +77,7 @@ function ToolContent({ flags, brightness, audio, isPhone, onShift, - numCannons, gridColumns, fixtures, + numCannons, gridColumns, fixtures, layout, activePattern, onPatternSelect, playlistState }: { @@ -99,6 +103,7 @@ function ToolContent({ numCannons: number; gridColumns: number; fixtures?: PreviewFixture[]; + layout: Layout; activePattern: string | null; onPatternSelect: (id: string) => void; playlistState: PlaylistState | null; @@ -258,11 +263,15 @@ function ToolContent({ )} {tab === 'sequences' && ( - + )} {tab === 'playlist' && ( - [0]['playlistState']} /> + [0]['playlistState']} + layout={layout} + /> )} {tab === 'flags' && ( @@ -517,6 +526,7 @@ export default function Home() { const NUM_CANNONS = config?.numCannons ?? 49; const GRID_COLUMNS = config?.gridColumns ?? 7; const FIXTURES = config?.layout?.fixtures; + const LAYOUT = config?.layout ?? FALLBACK_LAYOUT; const [tab, setTab] = useState(() => { if (typeof window === 'undefined') return 'paint'; @@ -841,6 +851,7 @@ export default function Home() { numCannons: NUM_CANNONS, gridColumns: GRID_COLUMNS, fixtures: FIXTURES, + layout: LAYOUT, activePattern, onPatternSelect: handlePatternSelect, playlistState diff --git a/packages/ui/src/components/layout-filter.tsx b/packages/ui/src/components/layout-filter.tsx new file mode 100644 index 0000000..0e16e51 --- /dev/null +++ b/packages/ui/src/components/layout-filter.tsx @@ -0,0 +1,66 @@ +import { layoutFilters } from '@wavegrid/animations'; +import type { Layout } from '@wavegrid/layout/client'; +import { useCallback, useState } from 'react'; + +/** + * Which rig the presets are being judged against. Remembered per surface, so + * an operator who works a Nova rig doesn't reselect it every time. + */ +export function useLayoutFilter(storageKey: string): [string, (id: string) => void] { + const [filter, setFilter] = useState(() => { + if (typeof window === 'undefined') return 'all'; + const saved = window.localStorage.getItem(storageKey); + return saved && layoutFilters().some(f => f.id === saved) ? saved : 'all'; + }); + + const choose = useCallback((id: string) => { + setFilter(id); + if (typeof window !== 'undefined') window.localStorage.setItem(storageKey, id); + }, [storageKey]); + + return [filter, choose]; +} + +/** + * Rig chips. "All" judges against the layout actually running, so the default + * view is honest about this installation; the named rigs let an operator build + * and check a show for a rig they are not standing in front of. + */ +export function LayoutFilterChips({ + value, + onChange, + layout +}: { + value: string; + onChange: (id: string) => void; + layout: Layout; +}) { + return ( +
+ {layoutFilters().map((f) => { + const selected = f.id === value; + const isThisRig = f.layout ? f.layout.id === layout.id : false; + return ( + + ); + })} +
+ ); +} diff --git a/packages/ui/src/components/playlist-tab.tsx b/packages/ui/src/components/playlist-tab.tsx index 05b04bb..f29d0b1 100644 --- a/packages/ui/src/components/playlist-tab.tsx +++ b/packages/ui/src/components/playlist-tab.tsx @@ -1,4 +1,8 @@ -import { useCallback, useEffect, useState } from 'react'; +import { layoutFilters, type LookDef, looksForLayout, type ShowPreset, showPresetsForLayout } from '@wavegrid/animations'; +import type { Layout } from '@wavegrid/layout/client'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { LayoutFilterChips, useLayoutFilter } from './layout-filter'; interface PlaylistStep { type: 'animation' | 'scene' | 'evalPattern'; @@ -7,6 +11,8 @@ interface PlaylistStep { duration: number; } +type Look = LookDef & { reason: string }; + interface PlaylistDef { steps: PlaylistStep[]; loop: boolean; @@ -14,77 +20,40 @@ interface PlaylistDef { transitionDuration: number; } -const AVAILABLE_ANIMATIONS = [ - 'wave', 'breathe', 'rainbow', 'pacman', 'spiral', 'rain', - 'i-heart-sf', 'heart-breathe', - 'pride-flow', 'pride-breathe', 'pride-rotate', 'pride-ring' -]; - -const AVAILABLE_SCENES = [ - 'civic', 'pride', 'gold', 'white', 'solstice', 'ocean', - 'sunset', 'heart', 'sf', 'forest', 'fire', 'night', 'checker', 'off' -]; - -const PRESETS: { name: string; playlist: PlaylistDef }[] = [ - { - name: 'Pride Show', - playlist: { - steps: [ - { type: 'animation', name: 'pride-flow', duration: 120 }, - { type: 'animation', name: 'pride-ring', duration: 120 }, - { type: 'animation', name: 'pride-breathe', duration: 60 }, - { type: 'animation', name: 'rainbow', duration: 120 }, - { type: 'animation', name: 'pride-rotate', duration: 60 } - ], - loop: true, - transition: 'fade', - transitionDuration: 2 - } - }, - { - name: 'SF Night', - playlist: { - steps: [ - { type: 'animation', name: 'i-heart-sf', duration: 180 }, - { type: 'animation', name: 'heart-breathe', duration: 120 }, - { type: 'scene', name: 'sf', duration: 60 }, - { type: 'animation', name: 'rainbow', duration: 120 } - ], - loop: true, - transition: 'fade', - transitionDuration: 3 - } - }, - { - name: 'Ambient', - playlist: { - steps: [ - { type: 'animation', name: 'wave', duration: 180 }, - { type: 'animation', name: 'breathe', duration: 120 }, - { type: 'animation', name: 'rain', duration: 180 }, - { type: 'animation', name: 'spiral', duration: 120 } - ], - loop: true, - transition: 'fade', - transitionDuration: 3 - } - } -]; - function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return m > 0 ? `${m}m ${s > 0 ? s + 's' : ''}`.trim() : `${s}s`; } +/** + * One option per look, labelled with what it needs when this rig can't give it + * — the list stays complete so a look is never silently missing. + */ +function LookOptions({ looks }: { looks: Look[] }) { + return ( + <> + {looks.map(l => ( + + ))} + + ); +} + function StepRow({ step, index, + animations, + scenes, onRemove, onUpdate }: { step: PlaylistStep; index: number; + animations: Look[]; + scenes: Look[]; onRemove: () => void; onUpdate: (s: PlaylistStep) => void; }) { @@ -105,8 +74,8 @@ function StepRow({ value={step.type} onChange={(e) => { const type = e.target.value as PlaylistStep['type']; - if (type === 'animation') onUpdate({ type, name: AVAILABLE_ANIMATIONS[0], duration: step.duration }); - else if (type === 'scene') onUpdate({ type, name: AVAILABLE_SCENES[0], duration: step.duration }); + if (type === 'animation') onUpdate({ type, name: animations[0]?.id, duration: step.duration }); + else if (type === 'scene') onUpdate({ type, name: scenes[0]?.id, duration: step.duration }); else onUpdate({ type, code: '({ render(ctx) { ctx.fill(ctx.t * 30 % 360, 100, 80); } })', duration: step.duration }); }} style={{ @@ -138,7 +107,7 @@ function StepRow({ color: '#c8c8d8' }} > - {AVAILABLE_ANIMATIONS.map(a => )} + )} @@ -156,7 +125,7 @@ function StepRow({ color: '#c8c8d8' }} > - {AVAILABLE_SCENES.map(s => )} + )} @@ -222,15 +191,30 @@ function StepRow({ export function PlaylistTab({ send, - playlistState + playlistState, + layout }: { send: (msg: Record) => void; playlistState: { active: boolean; playlist: PlaylistDef | null } | null; + layout: Layout; }) { + const [filter, setFilter] = useLayoutFilter('wavegrid-playlist-filter'); + + // "All" means this installation; a named rig lets an operator build a show + // for a rig they are not standing in front of. + const judgedAgainst = useMemo(() => { + const chosen = layoutFilters().find(f => f.id === filter); + return chosen?.layout ?? layout; + }, [filter, layout]); + + const animationLooks = useMemo(() => looksForLayout(judgedAgainst, 'animation'), [judgedAgainst]); + const sceneLooks = useMemo(() => looksForLayout(judgedAgainst, 'scene'), [judgedAgainst]); + const presets = useMemo(() => showPresetsForLayout(judgedAgainst, 'playlist'), [judgedAgainst]); + const [steps, setSteps] = useState([ - { type: 'animation', name: 'heart-breathe', duration: 120 }, - { type: 'animation', name: 'i-heart-sf', duration: 180 }, - { type: 'animation', name: 'rainbow', duration: 300 } + { type: 'animation', name: 'rainbow', duration: 180 }, + { type: 'animation', name: 'wave', duration: 180 }, + { type: 'animation', name: 'breathe', duration: 120 } ]); const [loop, setLoop] = useState(true); const [transition, setTransition] = useState<'cut' | 'fade'>('fade'); @@ -261,8 +245,8 @@ export function PlaylistTab({ }, [send]); const addStep = useCallback(() => { - setSteps(prev => [...prev, { type: 'animation', name: 'rainbow', duration: 60 }]); - }, []); + setSteps(prev => [...prev, { type: 'animation', name: animationLooks[0]?.id ?? 'rainbow', duration: 60 }]); + }, [animationLooks]); const removeStep = useCallback((idx: number) => { setSteps(prev => prev.filter((_, i) => i !== idx)); @@ -272,11 +256,11 @@ export function PlaylistTab({ setSteps(prev => prev.map((s, i) => i === idx ? step : s)); }, []); - const loadPreset = useCallback((preset: typeof PRESETS[number]) => { - setSteps(preset.playlist.steps); - setLoop(preset.playlist.loop); - setTransition(preset.playlist.transition); - setTransitionDuration(preset.playlist.transitionDuration); + const loadPreset = useCallback((preset: ShowPreset) => { + setSteps(preset.steps); + setLoop(preset.loop); + setTransition(preset.transition); + setTransitionDuration(preset.transitionDuration); }, []); const totalDuration = steps.reduce((acc, s) => acc + s.duration, 0); @@ -284,12 +268,15 @@ export function PlaylistTab({ return (
+ + {/* Presets */}
- {PRESETS.map((p) => ( + {presets.map((p) => (
))} @@ -288,21 +165,24 @@ export function SequencesTab({ {/* Sequence presets — fills remaining height, scrolls */}
-

- Sequences -

+
+

+ Sequences +

+ +
- {SEQUENCES.map((seq) => ( + {sequences.map((seq) => ( handlePlay(seq)} @@ -320,12 +200,13 @@ function SequenceCard({ active, onPlay }: { - sequence: SequenceDef; + sequence: Sequence; active: boolean; onPlay: () => void; }) { - const total = totalDuration(sequence.steps); + const total = showDuration(sequence.steps); const mins = Math.round(total / 60); + const unsuited = sequence.reason !== ''; return (