Skip to content

Commit be6a2cc

Browse files
RuitingMaclaude
andcommitted
ThreadSketch: empty cue is a cluster, not a column
Previously the empty cue rendered five threads at distinct y positions, which read as a calm column of parallel rows — not the intended "before-anything" state. Tighten it: - All five visible slots share yNorm = 0.5; per-slot freqMult / ampMult / phase make them weave through one another instead of lying flat. - Widen the per-slot freqMult / ampMult ranges (0.55..1.55, 0.65..1.35) so a stack at the same y still reads as distinct threads. - Bigger amplitude (22) and higher freq (1.0) for the empty cue, baseAlpha 0.95 so the cluster is punchy rather than wisp. - Slot 3 is now the "prominent" thread: rendered as accent-colored dots with hollow squares every 7th step, square sizes breathing on a slow per-square sine. New `dottedAmount` lerps between dotted and polyline rendering during cue transitions so the swap doesn't pop. All other cues keep dottedAmount = 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c935121 commit be6a2cc

1 file changed

Lines changed: 88 additions & 15 deletions

File tree

src/components/ThreadSketch.astro

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,12 @@
7171
}
7272
const slots: Slot[] = Array.from({ length: SLOTS }, () => ({
7373
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
7680
}));
7781

7882
// A preset is "what target state a cue maps to". yNorm is normalized
@@ -85,52 +89,70 @@
8589
zoned: number; // 0..1 — strength of 3-zone gradient
8690
pulses: number; // 0..2 — number of sweep bars
8791
lifecycleMask: number; // 0..1 — strength of traveling window
92+
dottedAmount: number; // 0..1 — render DOTTED_SLOT as dots + squares
8893
baseAlpha: number;
8994
}
9095

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+
91102
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.
93108
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,
97113
},
98-
// Same single thread, calmed down. The "lays flat" pose.
114+
// Single thread, calmed down. The "lays flat" pose.
99115
strip: {
100116
visible: [false, false, false, true, false, false, false, false],
101117
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,
103120
},
104121
// Same thread but now zone-colored: 3 segments, accent → fg-dim → accent.
105122
pipeline: {
106123
visible: [false, false, false, true, false, false, false, false],
107124
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,
109127
},
110128
// Eight threads in three loose bands. Top group, middle group,
111129
// bottom group — visually echoes pan / chrome / staff-lines.
112130
layers: {
113131
visible: [true, true, true, true, true, true, true, true],
114132
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,
116135
},
117136
// Five threads in the middle, but rendered through a moving window.
118137
lifecycle: {
119138
visible: [false, true, true, true, true, true, false, false],
120139
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,
122142
},
123143
// Six threads in two groups; two pulses sweep with phase offset.
124144
ticking: {
125145
visible: [true, true, true, false, false, true, true, true],
126146
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,
128149
},
129150
// Full weave, gentle breathing — every slot active.
130151
recap: {
131152
visible: [true, true, true, true, true, true, true, true],
132153
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,
134156
},
135157
};
136158

@@ -150,6 +172,7 @@
150172
zoned: number;
151173
pulses: number;
152174
lifecycleMask: number;
175+
dottedAmount: number;
153176
baseAlpha: number;
154177
};
155178

@@ -162,6 +185,7 @@
162185
zoned: p.zoned,
163186
pulses: p.pulses,
164187
lifecycleMask: p.lifecycleMask,
188+
dottedAmount: p.dottedAmount,
165189
baseAlpha: p.baseAlpha,
166190
};
167191
}
@@ -199,6 +223,42 @@
199223
const ro = new ResizeObserver(() => resize());
200224
ro.observe(canvas);
201225

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+
202262
function drawThread(slot: Slot, baseY: number, amplitude: number, freq: number, time: number, alpha: number, zoned: number) {
203263
if (alpha < 0.01) return;
204264
const STEP = 6;
@@ -248,16 +308,29 @@
248308
cur.zoned = lerp(cur.zoned, target.zoned, k);
249309
cur.pulses = lerp(cur.pulses, target.pulses, k);
250310
cur.lifecycleMask = lerp(cur.lifecycleMask, target.lifecycleMask, k);
311+
cur.dottedAmount = lerp(cur.dottedAmount, target.dottedAmount, k);
251312
cur.baseAlpha = lerp(cur.baseAlpha, target.baseAlpha, k);
252313
}
253314

254315
ctx!.clearRect(0, 0, w, h);
255316
ctx!.globalCompositeOperation = 'source-over';
256317

257318
// 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.
259323
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+
}
261334
}
262335
ctx!.globalAlpha = 1;
263336

0 commit comments

Comments
 (0)