|
| 1 | +// Browser-safe entry: this catalogue is read by the UI as well as the brain. |
| 2 | +import { type Layout, presets } from '@wavegrid/layout/client'; |
| 3 | + |
| 4 | +import { animations } from './animations'; |
| 5 | +import { scenes } from './scenes'; |
| 6 | + |
| 7 | +/** |
| 8 | + * What a look needs from the rig it runs on. |
| 9 | + * |
| 10 | + * Most looks read normalized fixture geometry and work anywhere, so `any` is |
| 11 | + * the default. The rest are honest about their requirement, because a bitmap |
| 12 | + * drawn for a 7-wide grid is meaningless on a ring and an operator should be |
| 13 | + * told that rather than discovering it on the lasers. |
| 14 | + */ |
| 15 | +export type Fits = |
| 16 | + | { needs: 'any' } |
| 17 | + /** Meaningful grid row/col, at least this big. */ |
| 18 | + | { needs: 'grid'; cols: number; rows: number } |
| 19 | + /** An ordered ring: fixtures the look can walk around a circle. */ |
| 20 | + | { needs: 'ring' } |
| 21 | + /** Written for specific installations, by layout id. */ |
| 22 | + | { needs: 'layout'; ids: string[] }; |
| 23 | + |
| 24 | +export interface LookDef { |
| 25 | + /** Wire name — the `name` field of an `animation`/`scene` command. */ |
| 26 | + id: string; |
| 27 | + kind: 'animation' | 'scene'; |
| 28 | + label: string; |
| 29 | + fits: Fits; |
| 30 | +} |
| 31 | + |
| 32 | +/** Human-readable reason a look does not suit a layout, or '' when it does. */ |
| 33 | +export function fitsReason(fits: Fits, layout: Layout): string { |
| 34 | + switch (fits.needs) { |
| 35 | + case 'any': |
| 36 | + return ''; |
| 37 | + case 'grid': |
| 38 | + if (!layout.hasGridCoords) return 'needs a grid'; |
| 39 | + return layout.cols >= fits.cols && layout.rows >= fits.rows |
| 40 | + ? '' |
| 41 | + : `needs ${fits.cols}×${fits.rows} or larger`; |
| 42 | + case 'ring': |
| 43 | + // Grids have no ring to travel around; every other topology does. |
| 44 | + return layout.topology === 'grid' ? 'needs a ring' : ''; |
| 45 | + case 'layout': |
| 46 | + return fits.ids.includes(layout.id) ? '' : `made for ${fits.ids.join(', ')}`; |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +export function fitsLayout(fits: Fits, layout: Layout): boolean { |
| 51 | + return fitsReason(fits, layout) === ''; |
| 52 | +} |
| 53 | + |
| 54 | +const ANY: Fits = { needs: 'any' }; |
| 55 | +const RING: Fits = { needs: 'ring' }; |
| 56 | +const ART_GRID: Fits = { needs: 'grid', cols: 7, rows: 7 }; |
| 57 | + |
| 58 | +/** |
| 59 | + * Every look the brain can run, with what it needs. Kept beside the registries |
| 60 | + * it describes, and a test fails if the two drift apart. |
| 61 | + */ |
| 62 | +export const LOOKS: LookDef[] = [ |
| 63 | + // ── Animations that adapt to any rig ── |
| 64 | + { id: 'wave', kind: 'animation', label: 'Wave', fits: ANY }, |
| 65 | + { id: 'breathe', kind: 'animation', label: 'Breathe', fits: ANY }, |
| 66 | + { id: 'rainbow', kind: 'animation', label: 'Rainbow', fits: ANY }, |
| 67 | + { id: 'spiral', kind: 'animation', label: 'Spiral', fits: ANY }, |
| 68 | + { id: 'rain', kind: 'animation', label: 'Rain', fits: ANY }, |
| 69 | + { id: 'pacman', kind: 'animation', label: 'Pacman', fits: ANY }, |
| 70 | + { id: 'pride-flow', kind: 'animation', label: 'Pride flow', fits: ANY }, |
| 71 | + { id: 'pride-breathe', kind: 'animation', label: 'Pride breathe', fits: ANY }, |
| 72 | + { id: 'pride-rotate', kind: 'animation', label: 'Pride rotate', fits: ANY }, |
| 73 | + { id: 'pride-ring', kind: 'animation', label: 'Pride ring', fits: ANY }, |
| 74 | + { id: 'trans-flow', kind: 'animation', label: 'Trans flow', fits: ANY }, |
| 75 | + { id: 'trans-breathe', kind: 'animation', label: 'Trans breathe', fits: ANY }, |
| 76 | + { id: 'trans-ring', kind: 'animation', label: 'Trans ring', fits: ANY }, |
| 77 | + |
| 78 | + // ── Bitmap art: drawn cell by cell for the 7-wide grid ── |
| 79 | + { id: 'i-heart-sf', kind: 'animation', label: 'I ♥ SF', fits: ART_GRID }, |
| 80 | + { id: 'heart-breathe', kind: 'animation', label: 'Heart breathe', fits: ART_GRID }, |
| 81 | + |
| 82 | + // ── Amber (Nova): brightness travelling around a circle ── |
| 83 | + { id: 'amber-chase', kind: 'animation', label: 'Amber chase', fits: RING }, |
| 84 | + { id: 'amber-comet', kind: 'animation', label: 'Amber comet', fits: RING }, |
| 85 | + { id: 'amber-wave', kind: 'animation', label: 'Amber wave', fits: RING }, |
| 86 | + { id: 'amber-levels', kind: 'animation', label: 'Amber levels', fits: RING }, |
| 87 | + { id: 'amber-heartbeat', kind: 'animation', label: 'Amber heartbeat', fits: RING }, |
| 88 | + { id: 'amber-embers', kind: 'animation', label: 'Amber embers', fits: RING }, |
| 89 | + { id: 'amber-breathe', kind: 'animation', label: 'Amber breathe', fits: ANY }, |
| 90 | + |
| 91 | + // ── Scenes ── |
| 92 | + { id: 'civic', kind: 'scene', label: 'Civic', fits: ANY }, |
| 93 | + { id: 'pride', kind: 'scene', label: 'Pride', fits: ANY }, |
| 94 | + { id: 'trans', kind: 'scene', label: 'Trans', fits: ANY }, |
| 95 | + { id: 'gold', kind: 'scene', label: 'Gold', fits: ANY }, |
| 96 | + { id: 'white', kind: 'scene', label: 'White', fits: ANY }, |
| 97 | + { id: 'solstice', kind: 'scene', label: 'Solstice', fits: ANY }, |
| 98 | + { id: 'ocean', kind: 'scene', label: 'Ocean', fits: ANY }, |
| 99 | + { id: 'sunset', kind: 'scene', label: 'Sunset', fits: ANY }, |
| 100 | + { id: 'forest', kind: 'scene', label: 'Forest', fits: ANY }, |
| 101 | + { id: 'fire', kind: 'scene', label: 'Fire', fits: ANY }, |
| 102 | + { id: 'off', kind: 'scene', label: 'Off', fits: ANY }, |
| 103 | + { id: 'night', kind: 'scene', label: 'Night', fits: ANY }, |
| 104 | + { id: 'checker', kind: 'scene', label: 'Checker', fits: ANY }, |
| 105 | + { id: 'heart', kind: 'scene', label: 'Heart', fits: ART_GRID }, |
| 106 | + { id: 'sf', kind: 'scene', label: 'SF', fits: ART_GRID }, |
| 107 | + { id: 'amber', kind: 'scene', label: 'Amber', fits: ANY }, |
| 108 | + { id: 'amber-glow', kind: 'scene', label: 'Amber glow', fits: ANY }, |
| 109 | + { id: 'amber-alternate', kind: 'scene', label: 'Amber alternate', fits: ANY }, |
| 110 | + { id: 'amber-ramp', kind: 'scene', label: 'Amber ramp', fits: RING }, |
| 111 | + { id: 'amber-horizon', kind: 'scene', label: 'Amber horizon', fits: RING } |
| 112 | +]; |
| 113 | + |
| 114 | +const BY_ID = new Map(LOOKS.map(l => [`${l.kind}:${l.id}`, l])); |
| 115 | + |
| 116 | +export function lookDef(kind: 'animation' | 'scene', id: string): LookDef | undefined { |
| 117 | + return BY_ID.get(`${kind}:${id}`); |
| 118 | +} |
| 119 | + |
| 120 | +/** Registry names with no catalog entry (or vice versa) — used by the drift test. */ |
| 121 | +export function catalogDrift(): { uncatalogued: string[]; missing: string[] } { |
| 122 | + const registered = [ |
| 123 | + ...Object.keys(animations).map(id => `animation:${id}`), |
| 124 | + ...Object.keys(scenes).map(id => `scene:${id}`) |
| 125 | + ]; |
| 126 | + return { |
| 127 | + uncatalogued: registered.filter(key => !BY_ID.has(key)), |
| 128 | + missing: [...BY_ID.keys()].filter(key => !registered.includes(key)) |
| 129 | + }; |
| 130 | +} |
| 131 | + |
| 132 | +/** |
| 133 | + * The whole catalogue, ones that suit this rig first. Nothing is removed: an |
| 134 | + * operator with a specific look in mind should see it and read why it is |
| 135 | + * unavailable, rather than wonder where it went. |
| 136 | + */ |
| 137 | +export function looksForLayout(layout: Layout, kind?: 'animation' | 'scene'): Array<LookDef & { reason: string }> { |
| 138 | + return LOOKS |
| 139 | + .filter(l => !kind || l.kind === kind) |
| 140 | + .map(l => ({ ...l, reason: fitsReason(l.fits, layout) })) |
| 141 | + .sort((a, b) => Number(!!a.reason) - Number(!!b.reason)); |
| 142 | +} |
| 143 | + |
| 144 | +/** |
| 145 | + * The rigs an operator builds shows for. Filtering is by *fit against a real |
| 146 | + * layout*, not by a tag someone remembered to set — so "Nova" means "runs on |
| 147 | + * the Nova ring", checked against the resolved preset. |
| 148 | + */ |
| 149 | +export interface LayoutFilter { |
| 150 | + id: string; |
| 151 | + label: string; |
| 152 | + /** Layout this filter judges fit against; null for "All". */ |
| 153 | + layout: Layout | null; |
| 154 | +} |
| 155 | + |
| 156 | +export function layoutFilters(): LayoutFilter[] { |
| 157 | + return [ |
| 158 | + { id: 'all', label: 'All', layout: null }, |
| 159 | + { id: 'grid-7x7', label: '7×7', layout: presets['grid-7x7']() }, |
| 160 | + { id: 'grace-cathedral', label: 'Grace', layout: presets['grace-cathedral']() }, |
| 161 | + { id: 'nova', label: 'Nova', layout: presets.nova() } |
| 162 | + ]; |
| 163 | +} |
0 commit comments