Skip to content

Commit 1cb594e

Browse files
authored
Merge pull request #122 from constructive-io/feat/layout-aware-shows
feat: sequences and playlists that know which rig they are for
2 parents f187c22 + 05a359f commit 1cb594e

14 files changed

Lines changed: 956 additions & 249 deletions

File tree

packages/animations/src/animations.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Fixture, Layout } from '@wavegrid/layout';
22

33
import { amberAnimations } from './amber';
4-
import { isArtGrid, prideColorAt, ROYGBIV, roygbivAt, setTarget, smooth } from './helpers';
4+
import { isArtGrid, prideColorAt, ROYGBIV, roygbivAt, setTarget, smooth, transColorAt } from './helpers';
55
import { AnimationFn, GridCell } from './types';
66

77
/**
@@ -179,6 +179,35 @@ animations['pride-ring'] = (grid, tick, attack, layout) => {
179179
});
180180
};
181181

182+
// ── Trans flag animations ────
183+
//
184+
// The same three shapes as the pride looks, on the trans palette — the
185+
// sequences have always asked for these by name.
186+
187+
animations['trans-flow'] = (grid, tick, attack, layout) => {
188+
const speed = tick * 0.012;
189+
layout.fixtures.forEach((f, i) => {
190+
const color = transColorAt(f.v + speed);
191+
setTarget(grid, i, color.h, color.s, 90, attack);
192+
});
193+
};
194+
195+
animations['trans-breathe'] = (grid, tick, attack) => {
196+
const color = transColorAt(tick * 0.008);
197+
const brightness = 70 + Math.sin(tick * 0.04) * 20;
198+
for (let i = 0; i < grid.length; i++) {
199+
setTarget(grid, i, color.h, color.s, brightness, attack);
200+
}
201+
};
202+
203+
animations['trans-ring'] = (grid, tick, attack, layout) => {
204+
const speed = tick * 0.012;
205+
layout.fixtures.forEach((f, i) => {
206+
const color = transColorAt(i / layout.count + speed);
207+
setTarget(grid, i, color.h, color.s, 90, attack);
208+
});
209+
};
210+
182211
// ── Amber (Nova) animations ────
183212

184213
Object.assign(animations, amberAnimations);

packages/animations/src/catalog.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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+
}

packages/animations/src/helpers.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,31 @@ export function roygbivAt(position: number): { h: number; s: number } {
9292
};
9393
}
9494

95+
/** The trans flag as a cyclic palette: blue, pink, white, pink, blue. */
96+
export const TRANS_COLORS: Array<{ h: number; s: number }> = [
97+
{ h: 197, s: 100 },
98+
{ h: 340, s: 60 },
99+
{ h: 0, s: 0 },
100+
{ h: 340, s: 60 },
101+
{ h: 197, s: 100 }
102+
];
103+
104+
/** Sample the trans palette at `position` (0–1, wrapping), blending stops. */
105+
export function transColorAt(position: number): { h: number; s: number } {
106+
const scaled = wrapUnit(position) * TRANS_COLORS.length;
107+
const idx = Math.floor(scaled);
108+
const mix = scaled - idx;
109+
const a = TRANS_COLORS[idx % TRANS_COLORS.length];
110+
const b = TRANS_COLORS[(idx + 1) % TRANS_COLORS.length];
111+
let dh = b.h - a.h;
112+
if (dh > 180) dh -= 360;
113+
if (dh < -180) dh += 360;
114+
return {
115+
h: (a.h + dh * mix + 360) % 360,
116+
s: a.s + (b.s - a.s) * mix
117+
};
118+
}
119+
95120
export function angleDelta(from: number, to: number): number {
96121
return ((to - from + 540) % 360) - 180;
97122
}

packages/animations/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,27 @@ export {
1414
roygbivAt,
1515
setTarget,
1616
smooth,
17+
TRANS_COLORS,
18+
transColorAt,
1719
wrapUnit
1820
} from './helpers';
1921

22+
// Catalogue: what each look needs from the rig it runs on
23+
export type { Fits, LayoutFilter, LookDef } from './catalog';
24+
export {
25+
catalogDrift,
26+
fitsLayout,
27+
fitsReason,
28+
layoutFilters,
29+
lookDef,
30+
LOOKS,
31+
looksForLayout
32+
} from './catalog';
33+
34+
// Preset shows (sequences and playlist starting points)
35+
export type { ShowPreset, ShowStep } from './shows';
36+
export { SHOW_PRESETS, showDuration, showFitsReason, showPresetsForLayout } from './shows';
37+
2038
// Amber (Nova) looks
2139
export type { AmberLook } from './amber';
2240
export {

0 commit comments

Comments
 (0)