|
71 | 71 | } |
72 | 72 | const slots: Slot[] = Array.from({ length: SLOTS }, () => ({ |
73 | 73 | phase: Math.random() * Math.PI * 2, |
74 | | - freqMult: 0.85 + Math.random() * 0.3, |
75 | | - ampMult: 0.85 + Math.random() * 0.3, |
| 74 | + // Wider range than feels natural for a single thread because the |
| 75 | + // empty cue stacks several slots at the SAME y — without per-slot |
| 76 | + // variation they'd alias into one fat smear instead of reading as |
| 77 | + // a cluster of distinct threads. |
| 78 | + freqMult: 0.55 + Math.random() * 1.0, // 0.55 .. 1.55 |
| 79 | + ampMult: 0.65 + Math.random() * 0.7, // 0.65 .. 1.35 |
76 | 80 | })); |
77 | 81 |
|
78 | 82 | // A preset is "what target state a cue maps to". yNorm is normalized |
|
85 | 89 | zoned: number; // 0..1 — strength of 3-zone gradient |
86 | 90 | pulses: number; // 0..2 — number of sweep bars |
87 | 91 | lifecycleMask: number; // 0..1 — strength of traveling window |
| 92 | + dottedAmount: number; // 0..1 — render DOTTED_SLOT as dots + squares |
88 | 93 | baseAlpha: number; |
89 | 94 | } |
90 | 95 |
|
| 96 | + // Slot 3 is reserved as the "prominent" thread: it's the middle slot, |
| 97 | + // visible across most cues, and the single slot that gets the dotted |
| 98 | + // / hollow-square treatment when a cue's dottedAmount > 0. Lerp on |
| 99 | + // dottedAmount cross-fades between dot rendering and polyline. |
| 100 | + const DOTTED_SLOT = 3; |
| 101 | + |
91 | 102 | const PRESETS: Record<string, Preset> = { |
92 | | - // One thread, alone, drifting more loosely. |
| 103 | + // A cluster of five threads sharing one y-axis but with different |
| 104 | + // freq/amp/phase (carried by slot.freqMult/ampMult/phase) — they |
| 105 | + // weave through each other instead of running as parallel rows. |
| 106 | + // Slot 3 is the prominent one: rendered as a dotted line |
| 107 | + // punctuated by hollow squares in accent. |
93 | 108 | empty: { |
94 | | - visible: [false, false, false, true, false, false, false, false], |
95 | | - yNorm: [0.30, 0.35, 0.40, 0.50, 0.60, 0.65, 0.70, 0.75], |
96 | | - amplitude: 8, freq: 0.45, zoned: 0, pulses: 0, lifecycleMask: 0, baseAlpha: 0.35, |
| 109 | + visible: [true, false, true, true, false, true, true, false], |
| 110 | + yNorm: [0.50, 0.50, 0.50, 0.50, 0.50, 0.50, 0.50, 0.50], |
| 111 | + amplitude: 22, freq: 1.0, zoned: 0, pulses: 0, lifecycleMask: 0, |
| 112 | + dottedAmount: 1, baseAlpha: 0.95, |
97 | 113 | }, |
98 | | - // Same single thread, calmed down. The "lays flat" pose. |
| 114 | + // Single thread, calmed down. The "lays flat" pose. |
99 | 115 | strip: { |
100 | 116 | visible: [false, false, false, true, false, false, false, false], |
101 | 117 | yNorm: [0.30, 0.35, 0.40, 0.50, 0.60, 0.65, 0.70, 0.75], |
102 | | - amplitude: 1.5, freq: 0.30, zoned: 0, pulses: 0, lifecycleMask: 0, baseAlpha: 0.75, |
| 118 | + amplitude: 1.5, freq: 0.30, zoned: 0, pulses: 0, lifecycleMask: 0, |
| 119 | + dottedAmount: 0, baseAlpha: 0.75, |
103 | 120 | }, |
104 | 121 | // Same thread but now zone-colored: 3 segments, accent → fg-dim → accent. |
105 | 122 | pipeline: { |
106 | 123 | visible: [false, false, false, true, false, false, false, false], |
107 | 124 | yNorm: [0.30, 0.35, 0.40, 0.50, 0.60, 0.65, 0.70, 0.75], |
108 | | - amplitude: 2, freq: 0.40, zoned: 1, pulses: 0, lifecycleMask: 0, baseAlpha: 0.85, |
| 125 | + amplitude: 2, freq: 0.40, zoned: 1, pulses: 0, lifecycleMask: 0, |
| 126 | + dottedAmount: 0, baseAlpha: 0.85, |
109 | 127 | }, |
110 | 128 | // Eight threads in three loose bands. Top group, middle group, |
111 | 129 | // bottom group — visually echoes pan / chrome / staff-lines. |
112 | 130 | layers: { |
113 | 131 | visible: [true, true, true, true, true, true, true, true], |
114 | 132 | yNorm: [0.22, 0.28, 0.34, 0.46, 0.52, 0.58, 0.70, 0.76], |
115 | | - amplitude: 4, freq: 0.50, zoned: 0, pulses: 0, lifecycleMask: 0, baseAlpha: 0.7, |
| 133 | + amplitude: 4, freq: 0.50, zoned: 0, pulses: 0, lifecycleMask: 0, |
| 134 | + dottedAmount: 0, baseAlpha: 0.7, |
116 | 135 | }, |
117 | 136 | // Five threads in the middle, but rendered through a moving window. |
118 | 137 | lifecycle: { |
119 | 138 | visible: [false, true, true, true, true, true, false, false], |
120 | 139 | yNorm: [0.25, 0.36, 0.43, 0.50, 0.57, 0.64, 0.72, 0.80], |
121 | | - amplitude: 3, freq: 0.50, zoned: 0, pulses: 0, lifecycleMask: 1, baseAlpha: 0.65, |
| 140 | + amplitude: 3, freq: 0.50, zoned: 0, pulses: 0, lifecycleMask: 1, |
| 141 | + dottedAmount: 0, baseAlpha: 0.65, |
122 | 142 | }, |
123 | 143 | // Six threads in two groups; two pulses sweep with phase offset. |
124 | 144 | ticking: { |
125 | 145 | visible: [true, true, true, false, false, true, true, true], |
126 | 146 | yNorm: [0.30, 0.36, 0.42, 0.50, 0.50, 0.58, 0.64, 0.70], |
127 | | - amplitude: 2, freq: 0.40, zoned: 0, pulses: 2, lifecycleMask: 0, baseAlpha: 0.75, |
| 147 | + amplitude: 2, freq: 0.40, zoned: 0, pulses: 2, lifecycleMask: 0, |
| 148 | + dottedAmount: 0, baseAlpha: 0.75, |
128 | 149 | }, |
129 | 150 | // Full weave, gentle breathing — every slot active. |
130 | 151 | recap: { |
131 | 152 | visible: [true, true, true, true, true, true, true, true], |
132 | 153 | yNorm: [0.22, 0.30, 0.38, 0.46, 0.54, 0.62, 0.70, 0.78], |
133 | | - amplitude: 3, freq: 0.45, zoned: 0, pulses: 0, lifecycleMask: 0, baseAlpha: 0.85, |
| 154 | + amplitude: 3, freq: 0.45, zoned: 0, pulses: 0, lifecycleMask: 0, |
| 155 | + dottedAmount: 0, baseAlpha: 0.85, |
134 | 156 | }, |
135 | 157 | }; |
136 | 158 |
|
|
150 | 172 | zoned: number; |
151 | 173 | pulses: number; |
152 | 174 | lifecycleMask: number; |
| 175 | + dottedAmount: number; |
153 | 176 | baseAlpha: number; |
154 | 177 | }; |
155 | 178 |
|
|
162 | 185 | zoned: p.zoned, |
163 | 186 | pulses: p.pulses, |
164 | 187 | lifecycleMask: p.lifecycleMask, |
| 188 | + dottedAmount: p.dottedAmount, |
165 | 189 | baseAlpha: p.baseAlpha, |
166 | 190 | }; |
167 | 191 | } |
|
199 | 223 | const ro = new ResizeObserver(() => resize()); |
200 | 224 | ro.observe(canvas); |
201 | 225 |
|
| 226 | + // The dotted/hollow-square thread: a row of small accent-colored dots |
| 227 | + // along the same wobble path as a regular thread, with an evenly- |
| 228 | + // spaced hollow square punctuating every Nth dot. Square size breathes |
| 229 | + // on a slow sine so the row doesn't read as perfectly mechanical. Used |
| 230 | + // only for slot DOTTED_SLOT when a cue's dottedAmount > 0. |
| 231 | + function drawDottedSquaredThread(slot: Slot, baseY: number, amplitude: number, freq: number, time: number, alpha: number) { |
| 232 | + if (alpha < 0.01) return; |
| 233 | + const DOT_SPACING = 7; |
| 234 | + const DOT_R = 1.2; |
| 235 | + const SQUARE_EVERY = 7; |
| 236 | + const SQUARE_BASE = 5; |
| 237 | + ctx!.fillStyle = accent; |
| 238 | + ctx!.strokeStyle = accent; |
| 239 | + ctx!.lineWidth = 1.0; |
| 240 | + let i = 0; |
| 241 | + for (let x = 0; x <= w; x += DOT_SPACING, i++) { |
| 242 | + const wave = amplitude * slot.ampMult * Math.sin(slot.freqMult * freq * (x / w) * Math.PI * 4 + slot.phase + time * 0.0006); |
| 243 | + const y = baseY + wave; |
| 244 | + if (i % SQUARE_EVERY === 0) { |
| 245 | + // Slow per-square breath: each square has its own phase via i, |
| 246 | + // so the row reads as breathing rather than pulsing in unison. |
| 247 | + const sizeJitter = 1.6 * Math.sin(time * 0.0009 + slot.phase + i * 0.7); |
| 248 | + const size = SQUARE_BASE + sizeJitter; |
| 249 | + ctx!.globalAlpha = alpha; |
| 250 | + ctx!.strokeRect(x - size / 2, y - size / 2, size, size); |
| 251 | + } else { |
| 252 | + // Dots a touch quieter than the squares so the squares lead. |
| 253 | + ctx!.globalAlpha = alpha * 0.85; |
| 254 | + ctx!.beginPath(); |
| 255 | + ctx!.arc(x, y, DOT_R, 0, Math.PI * 2); |
| 256 | + ctx!.fill(); |
| 257 | + } |
| 258 | + } |
| 259 | + ctx!.globalAlpha = 1; |
| 260 | + } |
| 261 | + |
202 | 262 | function drawThread(slot: Slot, baseY: number, amplitude: number, freq: number, time: number, alpha: number, zoned: number) { |
203 | 263 | if (alpha < 0.01) return; |
204 | 264 | const STEP = 6; |
|
248 | 308 | cur.zoned = lerp(cur.zoned, target.zoned, k); |
249 | 309 | cur.pulses = lerp(cur.pulses, target.pulses, k); |
250 | 310 | cur.lifecycleMask = lerp(cur.lifecycleMask, target.lifecycleMask, k); |
| 311 | + cur.dottedAmount = lerp(cur.dottedAmount, target.dottedAmount, k); |
251 | 312 | cur.baseAlpha = lerp(cur.baseAlpha, target.baseAlpha, k); |
252 | 313 | } |
253 | 314 |
|
254 | 315 | ctx!.clearRect(0, 0, w, h); |
255 | 316 | ctx!.globalCompositeOperation = 'source-over'; |
256 | 317 |
|
257 | 318 | // Draw threads. yNorm * h resolves to current pixel position so |
258 | | - // resize-time changes carry through automatically. |
| 319 | + // resize-time changes carry through automatically. Slot DOTTED_SLOT |
| 320 | + // crossfades between polyline and dotted/squared rendering based |
| 321 | + // on cur.dottedAmount — both modes drawn at proportional alpha |
| 322 | + // during transitions so the swap doesn't pop. |
259 | 323 | for (let i = 0; i < SLOTS; i++) { |
260 | | - drawThread(slots[i], cur.yNorm[i] * h, cur.amplitude, cur.freq, time, cur.alpha[i] * cur.baseAlpha, cur.zoned); |
| 324 | + const baseY = cur.yNorm[i] * h; |
| 325 | + const slotA = cur.alpha[i] * cur.baseAlpha; |
| 326 | + if (i === DOTTED_SLOT && cur.dottedAmount > 0.01) { |
| 327 | + drawDottedSquaredThread(slots[i], baseY, cur.amplitude, cur.freq, time, slotA * cur.dottedAmount); |
| 328 | + if (cur.dottedAmount < 0.99) { |
| 329 | + drawThread(slots[i], baseY, cur.amplitude, cur.freq, time, slotA * (1 - cur.dottedAmount), cur.zoned); |
| 330 | + } |
| 331 | + } else { |
| 332 | + drawThread(slots[i], baseY, cur.amplitude, cur.freq, time, slotA, cur.zoned); |
| 333 | + } |
261 | 334 | } |
262 | 335 | ctx!.globalAlpha = 1; |
263 | 336 |
|
|
0 commit comments