Skip to content

Commit e558ca5

Browse files
darkspockclaude
andcommitted
Add orchestra-specific animation to /orchestras page
Hub-and-spoke pattern: large Director center node with 9 "Musicians" orbiting. Three packet types: directives (director→musician), results (musician→director), and lateral (musician→musician). Much higher communication density than homepage animation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 793532c commit e558ca5

2 files changed

Lines changed: 338 additions & 0 deletions

File tree

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
---
2+
// Orchestra animation — a Director hub coordinates "Musicians" in a radial pattern.
3+
// Many more communication channels than the homepage orb network.
4+
// Movement is organized (hub-spoke + musician-to-musician), not random drift.
5+
---
6+
7+
<canvas id="orchestra-network" class="absolute inset-0 w-full h-full" style="pointer-events: none;"></canvas>
8+
9+
<script>
10+
const canvas = document.getElementById('orchestra-network') as HTMLCanvasElement;
11+
if (!canvas) throw new Error('canvas not found');
12+
const ctx = canvas.getContext('2d')!;
13+
14+
let W = 0, H = 0;
15+
const DPR = Math.min(window.devicePixelRatio || 1, 2);
16+
17+
function resize() {
18+
const rect = canvas.parentElement!.getBoundingClientRect();
19+
W = rect.width;
20+
H = rect.height;
21+
canvas.width = W * DPR;
22+
canvas.height = H * DPR;
23+
canvas.style.width = W + 'px';
24+
canvas.style.height = H + 'px';
25+
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
26+
layoutNodes();
27+
}
28+
29+
// --- Nodes ---
30+
interface OrchestraNode {
31+
x: number; y: number;
32+
baseR: number; r: number;
33+
phase: number; speed: number;
34+
hue: number; sat: number; light: number;
35+
pulsePhase: number;
36+
role: 'director' | 'musician';
37+
orbitAngle: number;
38+
orbitRadius: number;
39+
orbitSpeed: number;
40+
}
41+
42+
const MUSICIAN_COUNT = 9;
43+
const nodes: OrchestraNode[] = [];
44+
45+
const musicianColors = [
46+
{ h: 200, s: 75, l: 55 }, // cyan
47+
{ h: 160, s: 60, l: 50 }, // teal
48+
{ h: 270, s: 60, l: 55 }, // violet
49+
{ h: 220, s: 80, l: 60 }, // blue
50+
{ h: 320, s: 55, l: 55 }, // magenta
51+
{ h: 180, s: 65, l: 50 }, // aqua
52+
{ h: 250, s: 70, l: 58 }, // purple
53+
{ h: 140, s: 55, l: 48 }, // green
54+
{ h: 30, s: 60, l: 55 }, // amber
55+
];
56+
57+
function layoutNodes() {
58+
nodes.length = 0;
59+
60+
const cx = W / 2;
61+
const cy = H * 0.48;
62+
const orbitR = Math.min(W, H) * 0.28;
63+
64+
// Director — center, larger
65+
nodes.push({
66+
x: cx, y: cy,
67+
baseR: 45, r: 45,
68+
phase: 0, speed: 0.004,
69+
hue: 235, sat: 85, light: 65,
70+
pulsePhase: 0,
71+
role: 'director',
72+
orbitAngle: 0,
73+
orbitRadius: 0,
74+
orbitSpeed: 0,
75+
});
76+
77+
// Musicians — radial orbit
78+
for (let i = 0; i < MUSICIAN_COUNT; i++) {
79+
const angle = (i / MUSICIAN_COUNT) * Math.PI * 2 - Math.PI / 2;
80+
const p = musicianColors[i % musicianColors.length];
81+
// Slight variation in orbit distance
82+
const rVariation = 0.85 + Math.random() * 0.3;
83+
nodes.push({
84+
x: cx + Math.cos(angle) * orbitR * rVariation,
85+
y: cy + Math.sin(angle) * orbitR * rVariation,
86+
baseR: 18 + Math.random() * 10,
87+
r: 0,
88+
phase: Math.random() * Math.PI * 2,
89+
speed: 0.005 + Math.random() * 0.003,
90+
hue: p.h, sat: p.s, light: p.l,
91+
pulsePhase: Math.random() * Math.PI * 2,
92+
role: 'musician',
93+
orbitAngle: angle,
94+
orbitRadius: orbitR * rVariation,
95+
orbitSpeed: 0.0003 + Math.random() * 0.0004,
96+
});
97+
}
98+
}
99+
100+
layoutNodes();
101+
102+
// --- Packets ---
103+
interface Packet {
104+
from: number; to: number;
105+
t: number; speed: number;
106+
hue: number;
107+
cx: number; cy: number;
108+
type: 'directive' | 'result' | 'lateral';
109+
}
110+
111+
const packets: Packet[] = [];
112+
let nextDirective = 0;
113+
let nextResult = 0;
114+
let nextLateral = 0;
115+
116+
function spawnDirective(now: number) {
117+
if (now < nextDirective) return;
118+
nextDirective = now + 300 + Math.random() * 400;
119+
120+
const to = 1 + Math.floor(Math.random() * MUSICIAN_COUNT);
121+
const a = nodes[0], b = nodes[to];
122+
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
123+
const dist = Math.hypot(b.x - a.x, b.y - a.y);
124+
const offset = dist * 0.15 * (Math.random() > 0.5 ? 1 : -1);
125+
const angle = Math.atan2(b.y - a.y, b.x - a.x) + Math.PI / 2;
126+
127+
packets.push({
128+
from: 0, to, t: 0,
129+
speed: 0.012 + Math.random() * 0.006,
130+
hue: 235,
131+
cx: mx + Math.cos(angle) * offset,
132+
cy: my + Math.sin(angle) * offset,
133+
type: 'directive',
134+
});
135+
}
136+
137+
function spawnResult(now: number) {
138+
if (now < nextResult) return;
139+
nextResult = now + 500 + Math.random() * 600;
140+
141+
const from = 1 + Math.floor(Math.random() * MUSICIAN_COUNT);
142+
const a = nodes[from], b = nodes[0];
143+
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
144+
const dist = Math.hypot(b.x - a.x, b.y - a.y);
145+
const offset = dist * 0.15 * (Math.random() > 0.5 ? 1 : -1);
146+
const angle = Math.atan2(b.y - a.y, b.x - a.x) + Math.PI / 2;
147+
148+
packets.push({
149+
from, to: 0, t: 0,
150+
speed: 0.01 + Math.random() * 0.005,
151+
hue: nodes[from].hue,
152+
cx: mx + Math.cos(angle) * offset,
153+
cy: my + Math.sin(angle) * offset,
154+
type: 'result',
155+
});
156+
}
157+
158+
function spawnLateral(now: number) {
159+
if (now < nextLateral) return;
160+
nextLateral = now + 700 + Math.random() * 900;
161+
162+
const from = 1 + Math.floor(Math.random() * MUSICIAN_COUNT);
163+
let to = 1 + Math.floor(Math.random() * (MUSICIAN_COUNT - 1));
164+
if (to >= from) to++;
165+
if (to > MUSICIAN_COUNT) to = 1;
166+
167+
const a = nodes[from], b = nodes[to];
168+
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
169+
// Curve toward center for lateral messages
170+
const centerX = nodes[0].x, centerY = nodes[0].y;
171+
const pullFactor = 0.3;
172+
173+
packets.push({
174+
from, to, t: 0,
175+
speed: 0.008 + Math.random() * 0.004,
176+
hue: a.hue,
177+
cx: mx + (centerX - mx) * pullFactor,
178+
cy: my + (centerY - my) * pullFactor,
179+
type: 'lateral',
180+
});
181+
}
182+
183+
// --- Drawing helpers ---
184+
function quadBezier(ax: number, ay: number, cx: number, cy: number, bx: number, by: number, t: number) {
185+
const u = 1 - t;
186+
return { x: u * u * ax + 2 * u * t * cx + t * t * bx, y: u * u * ay + 2 * u * t * cy + t * t * by };
187+
}
188+
189+
function drawConnections() {
190+
// Director to all musicians — persistent faint lines
191+
const d = nodes[0];
192+
for (let i = 1; i <= MUSICIAN_COUNT; i++) {
193+
const m = nodes[i];
194+
const alpha = 0.06;
195+
ctx.beginPath();
196+
ctx.moveTo(d.x, d.y);
197+
ctx.lineTo(m.x, m.y);
198+
ctx.strokeStyle = `hsla(235, 60%, 60%, ${alpha})`;
199+
ctx.lineWidth = 1;
200+
ctx.stroke();
201+
}
202+
203+
// Musician-to-musician ring connections
204+
for (let i = 1; i <= MUSICIAN_COUNT; i++) {
205+
const next = i < MUSICIAN_COUNT ? i + 1 : 1;
206+
const a = nodes[i], b = nodes[next];
207+
ctx.beginPath();
208+
ctx.moveTo(a.x, a.y);
209+
ctx.lineTo(b.x, b.y);
210+
ctx.strokeStyle = `hsla(200, 40%, 50%, 0.03)`;
211+
ctx.lineWidth = 0.5;
212+
ctx.stroke();
213+
}
214+
}
215+
216+
function drawNode(node: OrchestraNode) {
217+
const pulse = 0.5 + 0.5 * Math.sin(node.pulsePhase);
218+
const isDirector = node.role === 'director';
219+
const glowR = node.r * (isDirector ? 3.0 : 2.2);
220+
const coreAlpha = isDirector ? 0.18 + pulse * 0.08 : 0.10 + pulse * 0.05;
221+
222+
// Outer glow
223+
const grd = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, glowR);
224+
grd.addColorStop(0, `hsla(${node.hue}, ${node.sat}%, ${node.light}%, ${coreAlpha})`);
225+
grd.addColorStop(0.4, `hsla(${node.hue}, ${node.sat}%, ${node.light}%, ${coreAlpha * 0.5})`);
226+
grd.addColorStop(1, `hsla(${node.hue}, ${node.sat}%, ${node.light}%, 0)`);
227+
ctx.beginPath();
228+
ctx.arc(node.x, node.y, glowR, 0, Math.PI * 2);
229+
ctx.fillStyle = grd;
230+
ctx.fill();
231+
232+
// Inner bright core
233+
const core = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, node.r * 0.5);
234+
core.addColorStop(0, `hsla(${node.hue}, ${node.sat}%, ${node.light + 15}%, ${0.3 + pulse * 0.12})`);
235+
core.addColorStop(1, `hsla(${node.hue}, ${node.sat}%, ${node.light}%, 0)`);
236+
ctx.beginPath();
237+
ctx.arc(node.x, node.y, node.r * 0.5, 0, Math.PI * 2);
238+
ctx.fillStyle = core;
239+
ctx.fill();
240+
241+
// Director has an extra ring
242+
if (isDirector) {
243+
ctx.beginPath();
244+
ctx.arc(node.x, node.y, node.r * 0.9, 0, Math.PI * 2);
245+
ctx.strokeStyle = `hsla(${node.hue}, ${node.sat}%, ${node.light}%, ${0.08 + pulse * 0.04})`;
246+
ctx.lineWidth = 1.5;
247+
ctx.stroke();
248+
}
249+
}
250+
251+
function drawPacket(p: Packet) {
252+
const a = nodes[p.from], b = nodes[p.to];
253+
const pos = quadBezier(a.x, a.y, p.cx, p.cy, b.x, b.y, p.t);
254+
255+
const trailLen = p.type === 'directive' ? 8 : 5;
256+
const baseAlpha = p.type === 'directive' ? 0.7 : 0.5;
257+
const baseRadius = p.type === 'directive' ? 3.5 : 2.5;
258+
259+
// Trail
260+
for (let s = 0; s < trailLen; s++) {
261+
const tt = Math.max(0, p.t - s * 0.012);
262+
const tp = quadBezier(a.x, a.y, p.cx, p.cy, b.x, b.y, tt);
263+
const alpha = (1 - s / trailLen) * baseAlpha;
264+
const radius = baseRadius - s * 0.3;
265+
ctx.beginPath();
266+
ctx.arc(tp.x, tp.y, Math.max(0.5, radius), 0, Math.PI * 2);
267+
ctx.fillStyle = `hsla(${p.hue}, 80%, 70%, ${alpha})`;
268+
ctx.fill();
269+
}
270+
271+
// Leading glow
272+
const glowSize = p.type === 'directive' ? 14 : 10;
273+
const glow = ctx.createRadialGradient(pos.x, pos.y, 0, pos.x, pos.y, glowSize);
274+
glow.addColorStop(0, `hsla(${p.hue}, 90%, 80%, 0.5)`);
275+
glow.addColorStop(1, `hsla(${p.hue}, 90%, 80%, 0)`);
276+
ctx.beginPath();
277+
ctx.arc(pos.x, pos.y, glowSize, 0, Math.PI * 2);
278+
ctx.fillStyle = glow;
279+
ctx.fill();
280+
}
281+
282+
// --- Main loop ---
283+
function draw(now: number) {
284+
ctx.clearRect(0, 0, W, H);
285+
286+
const cx = W / 2;
287+
const cy = H * 0.48;
288+
289+
// Update musician positions (gentle orbit)
290+
for (let i = 1; i <= MUSICIAN_COUNT; i++) {
291+
const n = nodes[i];
292+
n.orbitAngle += n.orbitSpeed;
293+
n.x = cx + Math.cos(n.orbitAngle) * n.orbitRadius;
294+
n.y = cy + Math.sin(n.orbitAngle) * n.orbitRadius;
295+
}
296+
297+
// Update all nodes breathing/pulse
298+
for (const n of nodes) {
299+
n.phase += n.speed;
300+
n.r = n.baseR + Math.sin(n.phase) * n.baseR * 0.2;
301+
n.pulsePhase += 0.02;
302+
}
303+
304+
// Draw
305+
drawConnections();
306+
for (const n of nodes) drawNode(n);
307+
308+
// Spawn packets — much more frequent than homepage
309+
spawnDirective(now);
310+
spawnResult(now);
311+
spawnLateral(now);
312+
313+
// Animate packets
314+
for (let i = packets.length - 1; i >= 0; i--) {
315+
const p = packets[i];
316+
p.t += p.speed;
317+
if (p.t >= 1) {
318+
nodes[p.to].pulsePhase = 0; // pulse on arrival
319+
packets.splice(i, 1);
320+
continue;
321+
}
322+
drawPacket(p);
323+
}
324+
325+
requestAnimationFrame(draw);
326+
}
327+
328+
requestAnimationFrame(draw);
329+
330+
let resizeTimer: ReturnType<typeof setTimeout>;
331+
window.addEventListener('resize', () => {
332+
clearTimeout(resizeTimer);
333+
resizeTimer = setTimeout(resize, 200);
334+
});
335+
resize();
336+
</script>

website/src/pages/orchestras.astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
import Layout from '../layouts/Layout.astro'
3+
import OrchestraNetwork from '../components/OrchestraNetwork.astro'
34
45
const features = [
56
{
@@ -65,6 +66,7 @@ const comparison = [
6566

6667
<Layout title="Orchestras — CronControl" description="AI-powered workflow orchestration with human-in-the-loop, real-time chat, and multi-model AI directors.">
6768
<section class="relative overflow-hidden">
69+
<OrchestraNetwork />
6870
<div class="hero-glow absolute inset-0"></div>
6971
<div class="grid-bg absolute inset-0"></div>
7072

0 commit comments

Comments
 (0)