Skip to content

Commit c935121

Browse files
RuitingMaclaude
andcommitted
ThreadSketch: cue-driven weave for 滚谱的扎法
A horizontal field of threads (max 8 slots) that lerps between cue presets as the reading-line observer in EssayLayout fires stage:cue. Each preset pairs with a structural insight in the prose: empty — one thread drifting alone. strip — same thread laid flat. pipeline — same thread but zone-colored (accent → fg-dim → accent). layers — eight threads in three loose vertical groups. lifecycle — five threads visible through a traveling window mask. ticking — six threads + two phase-offset accent sweep bars. recap — full weave, gentle breathing. Implementation notes: - Y positions stored as normalized [0..1] so resize doesn't strand threads at stale pixel coords; multiply by current h at draw time. Initial render relies on a ResizeObserver since the sticky stage's 100vh resolves after the ctor's first resize() call. - Tint reading: scoped tint classes (.tint-dune etc) live on the article, not :root — so we read CSS vars from the tinted article when present, otherwise fall back to documentElement defaults. - Lerp half-life ≈ 250ms; transitions feel confident without snapping. Wired into the STAGES registry as `thread`. 滚谱的扎法 opts in via its frontmatter `stage: thread`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1b7d379 commit c935121

3 files changed

Lines changed: 348 additions & 0 deletions

File tree

src/components/ThreadSketch.astro

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
---
2+
/*
3+
* Thread sketch — 扎 / a horizontal weave that responds to cues.
4+
*
5+
* Stage for engineering essays whose prose moves through structural
6+
* states. Listens for `stage:cue` events from EssayLayout's reading-
7+
* line observer and shifts a field of horizontal threads through
8+
* preset configurations. Each cue's visual state pairs with a
9+
* structural insight in the prose:
10+
*
11+
* empty — one thread drifting; the "before" state.
12+
* strip — the thread lays flat across the canvas.
13+
* pipeline — the thread colored in three zones (raw → done).
14+
* layers — eight threads arranged in three stacked groups.
15+
* lifecycle — threads visible only through a moving window mask.
16+
* ticking — two perpendicular pulse bars sweep with phase offset.
17+
* recap — full weave, gentle breathing.
18+
*
19+
* Per-frame: lerp current params toward target (cue-driven), draw
20+
* threads as sine-wobbled polylines, optionally apply a window mask
21+
* or sweep bars. Threads have stable random phase + amplitude
22+
* multipliers so transitions look organic rather than synced.
23+
*
24+
* Lifecycle: see src/lib/sketch-lifecycle.ts.
25+
*/
26+
---
27+
28+
<canvas id="thread"></canvas>
29+
30+
<script>
31+
import { mountSketch } from '../lib/sketch-lifecycle';
32+
33+
mountSketch(() => {
34+
const canvas = document.getElementById('thread') as HTMLCanvasElement | null;
35+
if (!canvas) return;
36+
const ctx = canvas.getContext('2d');
37+
if (!ctx) return;
38+
39+
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
40+
let w = 0, h = 0;
41+
42+
function resize() {
43+
const rect = canvas!.getBoundingClientRect();
44+
w = rect.width;
45+
h = rect.height;
46+
canvas!.width = w * dpr;
47+
canvas!.height = h * dpr;
48+
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
49+
}
50+
resize();
51+
52+
// Read CSS variables once at mount. Tint classes are scoped to the
53+
// article (e.g. `.tint-dune`), not :root — so reading from
54+
// documentElement gives us the un-tinted defaults (--accent === --fg,
55+
// i.e. nearly white). Read from the tinted article when present so
56+
// pulses and zone gradients pick up the page's accent. We're torn
57+
// down + remounted on SPA swap so any tint change arrives fresh.
58+
const tinted = document.querySelector('article[class*="tint-"]') ?? document.documentElement;
59+
const cs = getComputedStyle(tinted);
60+
const fgDim = (cs.getPropertyValue('--fg-dim').trim() || '#aaa');
61+
const accent = (cs.getPropertyValue('--accent').trim() || '#b6a078');
62+
63+
// Eight thread slots. Each slot is always processed each frame; its
64+
// alpha + y are driven by the active preset. Empty cues just hide
65+
// most slots by lerping their alpha to 0.
66+
const SLOTS = 8;
67+
interface Slot {
68+
phase: number;
69+
freqMult: number;
70+
ampMult: number;
71+
}
72+
const slots: Slot[] = Array.from({ length: SLOTS }, () => ({
73+
phase: Math.random() * Math.PI * 2,
74+
freqMult: 0.85 + Math.random() * 0.3,
75+
ampMult: 0.85 + Math.random() * 0.3,
76+
}));
77+
78+
// A preset is "what target state a cue maps to". yNorm is normalized
79+
// [0,1] vertical position; visible booleans become alpha targets.
80+
interface Preset {
81+
visible: boolean[];
82+
yNorm: number[];
83+
amplitude: number;
84+
freq: number;
85+
zoned: number; // 0..1 — strength of 3-zone gradient
86+
pulses: number; // 0..2 — number of sweep bars
87+
lifecycleMask: number; // 0..1 — strength of traveling window
88+
baseAlpha: number;
89+
}
90+
91+
const PRESETS: Record<string, Preset> = {
92+
// One thread, alone, drifting more loosely.
93+
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,
97+
},
98+
// Same single thread, calmed down. The "lays flat" pose.
99+
strip: {
100+
visible: [false, false, false, true, false, false, false, false],
101+
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,
103+
},
104+
// Same thread but now zone-colored: 3 segments, accent → fg-dim → accent.
105+
pipeline: {
106+
visible: [false, false, false, true, false, false, false, false],
107+
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,
109+
},
110+
// Eight threads in three loose bands. Top group, middle group,
111+
// bottom group — visually echoes pan / chrome / staff-lines.
112+
layers: {
113+
visible: [true, true, true, true, true, true, true, true],
114+
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,
116+
},
117+
// Five threads in the middle, but rendered through a moving window.
118+
lifecycle: {
119+
visible: [false, true, true, true, true, true, false, false],
120+
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,
122+
},
123+
// Six threads in two groups; two pulses sweep with phase offset.
124+
ticking: {
125+
visible: [true, true, true, false, false, true, true, true],
126+
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,
128+
},
129+
// Full weave, gentle breathing — every slot active.
130+
recap: {
131+
visible: [true, true, true, true, true, true, true, true],
132+
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,
134+
},
135+
};
136+
137+
// Initial state — start at "empty" so a fresh page reads as a held
138+
// breath even before the first cue fires (compute in EssayLayout
139+
// primes a cue immediately, but rAF may render a frame first).
140+
//
141+
// y is stored as a *normalized* fraction (0..1) so a resize doesn't
142+
// strand thread positions in stale pixel coordinates; we multiply
143+
// by current h at draw time. Amplitude stays in pixels — wobble
144+
// height should feel the same regardless of canvas height.
145+
type AnimState = {
146+
alpha: number[]; // per slot 0..1
147+
yNorm: number[]; // per slot 0..1
148+
amplitude: number;
149+
freq: number;
150+
zoned: number;
151+
pulses: number;
152+
lifecycleMask: number;
153+
baseAlpha: number;
154+
};
155+
156+
function fromPreset(p: Preset): AnimState {
157+
return {
158+
alpha: p.visible.map((v) => (v ? 1 : 0)),
159+
yNorm: [...p.yNorm],
160+
amplitude: p.amplitude,
161+
freq: p.freq,
162+
zoned: p.zoned,
163+
pulses: p.pulses,
164+
lifecycleMask: p.lifecycleMask,
165+
baseAlpha: p.baseAlpha,
166+
};
167+
}
168+
169+
let cur: AnimState = fromPreset(PRESETS.empty);
170+
let target: AnimState = fromPreset(PRESETS.empty);
171+
172+
function setCue(name: string) {
173+
const p = PRESETS[name];
174+
if (!p) return;
175+
target = fromPreset(p);
176+
}
177+
178+
function lerp(a: number, b: number, t: number) { return a + (b - a) * t; }
179+
function lerpArr(a: number[], b: number[], t: number) {
180+
const out = new Array(a.length);
181+
for (let i = 0; i < a.length; i++) out[i] = lerp(a[i], b[i], t);
182+
return out;
183+
}
184+
185+
let paused = false;
186+
function onPause(e: Event) {
187+
paused = !!(e as CustomEvent).detail?.paused;
188+
}
189+
function onCue(e: Event) {
190+
const cue = (e as CustomEvent).detail?.cue;
191+
if (typeof cue === 'string') setCue(cue);
192+
}
193+
window.addEventListener('stage:cue', onCue);
194+
window.addEventListener('stage:pause', onPause);
195+
196+
// ResizeObserver picks up the canvas's actual layout size — the
197+
// sticky stage may resolve its 100vh after our initial resize()
198+
// runs, and pure window.resize doesn't fire on layout settles.
199+
const ro = new ResizeObserver(() => resize());
200+
ro.observe(canvas);
201+
202+
function drawThread(slot: Slot, baseY: number, amplitude: number, freq: number, time: number, alpha: number, zoned: number) {
203+
if (alpha < 0.01) return;
204+
const STEP = 6;
205+
ctx!.beginPath();
206+
for (let x = 0; x <= w; x += STEP) {
207+
const wave = amplitude * slot.ampMult * Math.sin(slot.freqMult * freq * (x / w) * Math.PI * 4 + slot.phase + time * 0.0006);
208+
const y = baseY + wave;
209+
if (x === 0) ctx!.moveTo(x, y);
210+
else ctx!.lineTo(x, y);
211+
}
212+
if (zoned > 0.01) {
213+
// Three-zone gradient: accent → fg-dim → accent. Mix toward
214+
// pure fg-dim as zoned decreases so the transition lerps
215+
// smoothly between zoned and unzoned states.
216+
const grad = ctx!.createLinearGradient(0, 0, w, 0);
217+
grad.addColorStop(0, accent);
218+
grad.addColorStop(0.32, accent);
219+
grad.addColorStop(0.34, fgDim);
220+
grad.addColorStop(0.66, fgDim);
221+
grad.addColorStop(0.68, accent);
222+
grad.addColorStop(1, accent);
223+
ctx!.strokeStyle = grad;
224+
ctx!.globalAlpha = alpha * (0.35 + 0.5 * zoned);
225+
} else {
226+
ctx!.strokeStyle = fgDim;
227+
ctx!.globalAlpha = alpha;
228+
}
229+
ctx!.lineWidth = 1.0;
230+
ctx!.stroke();
231+
}
232+
233+
let aborted = false;
234+
let rafId = 0;
235+
let last = performance.now();
236+
237+
function frame(time: number) {
238+
if (aborted) return;
239+
const dt = Math.min(time - last, 64);
240+
last = time;
241+
if (!paused) {
242+
// ~250ms half-life feels confident without snapping.
243+
const k = 1 - Math.exp(-dt / 250);
244+
cur.alpha = lerpArr(cur.alpha, target.alpha, k);
245+
cur.yNorm = lerpArr(cur.yNorm, target.yNorm, k);
246+
cur.amplitude = lerp(cur.amplitude, target.amplitude, k);
247+
cur.freq = lerp(cur.freq, target.freq, k);
248+
cur.zoned = lerp(cur.zoned, target.zoned, k);
249+
cur.pulses = lerp(cur.pulses, target.pulses, k);
250+
cur.lifecycleMask = lerp(cur.lifecycleMask, target.lifecycleMask, k);
251+
cur.baseAlpha = lerp(cur.baseAlpha, target.baseAlpha, k);
252+
}
253+
254+
ctx!.clearRect(0, 0, w, h);
255+
ctx!.globalCompositeOperation = 'source-over';
256+
257+
// Draw threads. yNorm * h resolves to current pixel position so
258+
// resize-time changes carry through automatically.
259+
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);
261+
}
262+
ctx!.globalAlpha = 1;
263+
264+
// Lifecycle window mask: keep only what's inside a feathered
265+
// band that travels left → right. After it exits the right, a
266+
// brief gap, then it re-enters from the left. Reads as: "things
267+
// mount, drift across, unmount."
268+
if (cur.lifecycleMask > 0.01) {
269+
const period = 5200;
270+
const t = (time % period) / period; // 0..1
271+
const winCx = -0.25 * w + t * (1.5 * w); // sweep -25%..125%
272+
const winHalf = w * 0.18;
273+
const feather = w * 0.10;
274+
ctx!.globalCompositeOperation = 'destination-in';
275+
const grad = ctx!.createLinearGradient(0, 0, w, 0);
276+
const stops: [number, string][] = [
277+
[0, 'rgba(0,0,0,0)'],
278+
[Math.max(0, (winCx - winHalf - feather) / w), 'rgba(0,0,0,0)'],
279+
[Math.max(0, (winCx - winHalf) / w), `rgba(0,0,0,${cur.lifecycleMask})`],
280+
[Math.min(1, (winCx + winHalf) / w), `rgba(0,0,0,${cur.lifecycleMask})`],
281+
[Math.min(1, (winCx + winHalf + feather) / w), 'rgba(0,0,0,0)'],
282+
[1, 'rgba(0,0,0,0)'],
283+
];
284+
// Sort by stop position (Math.max/min may have produced unsorted)
285+
stops.sort((a, b) => a[0] - b[0]);
286+
for (const [stop, color] of stops) {
287+
if (stop >= 0 && stop <= 1) grad.addColorStop(stop, color);
288+
}
289+
ctx!.fillStyle = grad;
290+
ctx!.fillRect(0, 0, w, h);
291+
292+
// Outside the mask we want full transparency (1 - lifecycleMask
293+
// gives a pass-through during transitions so the threads aren't
294+
// erased mid-fade between cues).
295+
ctx!.globalCompositeOperation = 'destination-over';
296+
ctx!.fillStyle = `rgba(0,0,0,${1 - cur.lifecycleMask})`;
297+
ctx!.fillRect(0, 0, w, h);
298+
ctx!.globalCompositeOperation = 'source-over';
299+
}
300+
301+
// Pulse bars (ticking cue). Two slim accent-colored sweeps, second
302+
// offset by a small phase so they read as not-quite-synced — the
303+
// visual analog of rAF vs AudioContext.
304+
const pulseCount = Math.round(cur.pulses);
305+
if (pulseCount > 0) {
306+
const period = 3800;
307+
const bandTop = h * 0.18;
308+
const bandH = h * 0.64;
309+
for (let p = 0; p < pulseCount; p++) {
310+
const phaseOffset = p * 0.16;
311+
const t = (((time / period) + phaseOffset) % 1 + 1) % 1;
312+
const x = t * w;
313+
const half = 22;
314+
const grad = ctx!.createLinearGradient(x - half, 0, x + half, 0);
315+
grad.addColorStop(0, 'rgba(0,0,0,0)');
316+
grad.addColorStop(0.5, accent);
317+
grad.addColorStop(1, 'rgba(0,0,0,0)');
318+
ctx!.globalAlpha = 0.55 * (cur.pulses / pulseCount);
319+
ctx!.fillStyle = grad;
320+
ctx!.fillRect(x - half, bandTop, half * 2, bandH);
321+
}
322+
ctx!.globalAlpha = 1;
323+
}
324+
325+
rafId = requestAnimationFrame(frame);
326+
}
327+
rafId = requestAnimationFrame(frame);
328+
329+
return () => {
330+
aborted = true;
331+
cancelAnimationFrame(rafId);
332+
window.removeEventListener('stage:cue', onCue);
333+
window.removeEventListener('stage:pause', onPause);
334+
ro.disconnect();
335+
};
336+
});
337+
</script>
338+
339+
<style>
340+
canvas#thread {
341+
display: block;
342+
width: 100%;
343+
height: 100%;
344+
}
345+
</style>

src/content/essays/gun-pu-de-zha-fa.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ date: "2026-05-04"
44
kind: ESSAY
55
summary: 一份能在网页里横向滚动、声音同步的乐谱组件,从 MEI 到 DOM 怎么一步步扎起来的。
66
tint: dune
7+
stage: thread
78
---
89

910
import Score from '../../components/Score.astro';

src/pages/essays/[...slug].astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { getCollection, render } from 'astro:content';
33
import EssayLayout from '../../layouts/EssayLayout.astro';
44
import BeginSketch from '../../components/BeginSketch.astro';
5+
import ThreadSketch from '../../components/ThreadSketch.astro';
56
67
export async function getStaticPaths() {
78
const essays = await getCollection('essays', (e) => import.meta.env.DEV || e.data.published);
@@ -17,6 +18,7 @@ export async function getStaticPaths() {
1718
// per voice, not one per essay.
1819
const STAGES = {
1920
begin: BeginSketch,
21+
thread: ThreadSketch,
2022
} as const;
2123
type StageKey = keyof typeof STAGES;
2224

0 commit comments

Comments
 (0)