|
| 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> |
0 commit comments