Skip to content

Commit ef38f27

Browse files
RuitingMaclaude
andcommitted
Add essay content collection + reactive-canvas long-form
- `essays` content collection under src/content/essays/, loaded by a dynamic /essays/[...slug] route. Homepage feed merges essays with the existing hand-authored sketch list, date-sorted. - `EssayLayout` drives a reading-line scroll observer that dispatches `stage:cue` events on the window when `[data-cue]` sections pass the playhead line. Cleanup via astro:before-swap so ClientRouter nav doesn't leak observers. - `BeginSketch` extended with a cue vocabulary: core states (empty/drop/many/still) plus grid-wave cues (sq/hex × rows/cols/diag) that emit ripples from a lattice of points, and size modifiers (small/large) that stick across cue changes. - First essay: 随步换景 — on reading-position as composition. - Minor: clinamen page text tweak; __pycache__ added to gitignore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 55dfa90 commit ef38f27

9 files changed

Lines changed: 829 additions & 24 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ pnpm-debug.log*
2323
# jetbrains setting folder
2424
.idea/
2525

26+
# python bytecode cache from scripts/build-fonts.py
27+
__pycache__/
28+
2629
# local Claude Code config (launch.json is per-machine)
2730
.claude/
2831

src/components/BeginSketch.astro

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
---
2+
/*
3+
* Begin sketch — 起 / ripple pond
4+
*
5+
* Stage for essays that want a quiet water-surface accompaniment. Listens
6+
* for `stage:cue` events dispatched by EssayLayout's IntersectionObserver
7+
* and shifts between named states. The cue names are pure conventions —
8+
* any essay whose frontmatter sets stage: 'begin' can use them by
9+
* annotating its sections with data-cue="<name>".
10+
*
11+
* Core cues:
12+
* empty — nothing spawns; a held breath.
13+
* drop — one strong centered drop on entry, then quiet.
14+
* many — frequent random spawns across the whole surface.
15+
* still — slow, weak spawns that fade gently.
16+
*
17+
* Grid-wave cues — ripples spawn at vertices of a tiled lattice,
18+
* triggered in a staggered sweep. The first wave fires on entry; a
19+
* fresh wave repeats every ~3.5s until the cue changes. Pattern is
20+
* "<tiling>-<sweep>":
21+
* sq-rows / sq-cols / sq-diag
22+
* hex-rows / hex-cols / hex-diag
23+
*
24+
* Size modifiers (persist across cue changes until another modifier
25+
* cue fires — they affect whatever spawns next, grid or otherwise):
26+
* small — ripples at ~55% of their normal radius.
27+
* large — ripples at ~155%.
28+
* (default is 100%; any non-modifier cue does not reset this.)
29+
*/
30+
---
31+
32+
<canvas id="begin"></canvas>
33+
34+
<script>
35+
// See ParticleSketch for the init/cleanup lifecycle rationale. Same pattern.
36+
let currentCleanup: (() => void) | null = null;
37+
38+
type Tiling = 'sq' | 'hex';
39+
type Sweep = 'rows' | 'cols' | 'diag';
40+
41+
function init() {
42+
if (currentCleanup) return;
43+
const canvas = document.getElementById('begin') as HTMLCanvasElement | null;
44+
if (!canvas) return;
45+
46+
const ctx = canvas.getContext('2d')!;
47+
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
48+
let w = 0, h = 0;
49+
50+
function resize() {
51+
const rect = canvas!.getBoundingClientRect();
52+
w = rect.width;
53+
h = rect.height;
54+
canvas!.width = w * dpr;
55+
canvas!.height = h * dpr;
56+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
57+
}
58+
resize();
59+
60+
// A ripple carries its OWN maxR at birth so a cue change mid-flight
61+
// doesn't resize it. Without this, a tall `drop` ripple whose radius
62+
// is still expanding would snap to the smaller radius of `many` the
63+
// instant the cue changes — reads as the animation getting cut.
64+
type Ripple = { cx: number; cy: number; born: number; strength: number; maxR: number };
65+
const ripples: Ripple[] = [];
66+
const LIFETIME = 8000;
67+
const RING_COUNT = 4;
68+
const RING_STAGGER = 0.07;
69+
const EXPIRE_AT = 1 + (RING_COUNT - 1) * RING_STAGGER + 0.02;
70+
71+
// Base radii for the simple cues. `gap` null ⇒ no random auto-spawn
72+
// (either disarmed or superseded by a grid wave). `maxR` is a
73+
// fraction of min(w, h), resolved at spawn time.
74+
const CUE = {
75+
empty: { gap: null, maxR: 0.20, strength: 1 },
76+
drop: { gap: null, maxR: 0.36, strength: 1 },
77+
many: { gap: { min: 900, span: 1800 }, maxR: 0.22, strength: 1 },
78+
still: { gap: { min: 4200, span: 5200 }, maxR: 0.18, strength: 0.6 },
79+
} as const;
80+
type SimpleCue = keyof typeof CUE;
81+
82+
// Grid-wave parameters.
83+
const GRID_RIPPLE_RATIO = 0.09; // per-vertex ripple maxR (frac of min(w,h))
84+
const GRID_UNIT_DIV = 5.5; // canvas-short-side / UNIT_DIV ≈ rows across
85+
const SWEEP_DURATION = 1400; // ms from first vertex fires to last
86+
const WAVE_INTERVAL = 12000; // ms between repeating waves while cue is held
87+
88+
// Size modifier — multiplies every new ripple's maxR, whether from
89+
// drop / many / still / grid / pointer. Persists across cue changes
90+
// until `small` or `large` (or a reset back to 1, currently none).
91+
let sizeMultiplier = 1;
92+
93+
function spawn(cx: number, cy: number, strength: number, maxRRatio: number) {
94+
ripples.push({
95+
cx, cy,
96+
born: t,
97+
strength,
98+
maxR: Math.min(w, h) * maxRRatio * sizeMultiplier,
99+
});
100+
}
101+
102+
// Time of the most recent centered drop, so re-entering the `drop`
103+
// cue while the previous drop's ripple is still alive doesn't stack
104+
// a second set of concentric rings on top of the first.
105+
let lastDropAt = -Infinity;
106+
107+
// Current primary cue — tracked as free-form string because the
108+
// grid-wave names are parsed rather than pre-enumerated.
109+
let cue: string = 'empty';
110+
let t = 0;
111+
let lastNow = performance.now();
112+
let paused = false;
113+
114+
// Simple-cue auto-spawn scheduling (many / still).
115+
let nextSpawnAt = Infinity;
116+
117+
// Grid-wave state.
118+
let gridTiling: Tiling | null = null;
119+
let gridSweep: Sweep | null = null;
120+
let nextWaveAt = Infinity;
121+
// Scheduled ripples with future `at` timestamps. Drained each frame
122+
// as `t` crosses their fire time. Cleared on any non-modifier cue
123+
// change so a stale wave from the previous state can't continue
124+
// spawning under the new state.
125+
const schedule: Array<{ x: number; y: number; at: number; maxRRatio: number }> = [];
126+
127+
function computeGridPoints(tiling: Tiling): Array<{ i: number; j: number; x: number; y: number }> {
128+
const unit = Math.max(48, Math.min(w, h) / GRID_UNIT_DIV);
129+
const cols = Math.max(3, Math.floor(w / unit));
130+
const out: Array<{ i: number; j: number; x: number; y: number }> = [];
131+
if (tiling === 'sq') {
132+
const rowStep = unit;
133+
const rows = Math.max(3, Math.floor(h / rowStep));
134+
const xMargin = (w - (cols - 1) * unit) / 2;
135+
const yMargin = (h - (rows - 1) * rowStep) / 2;
136+
for (let j = 0; j < rows; j++)
137+
for (let i = 0; i < cols; i++)
138+
out.push({ i, j, x: xMargin + i * unit, y: yMargin + j * rowStep });
139+
} else {
140+
// Hex: row step = unit × √3/2, every other row offset by unit/2.
141+
// Pull the whole field back by unit/4 so the two row variants
142+
// share a visual center.
143+
const rowStep = unit * Math.sqrt(3) / 2;
144+
const rows = Math.max(3, Math.floor(h / rowStep));
145+
const xMargin = (w - (cols - 1) * unit) / 2 - unit * 0.25;
146+
const yMargin = (h - (rows - 1) * rowStep) / 2;
147+
for (let j = 0; j < rows; j++) {
148+
const offset = (j % 2) * (unit * 0.5);
149+
for (let i = 0; i < cols; i++)
150+
out.push({ i, j, x: xMargin + i * unit + offset, y: yMargin + j * rowStep });
151+
}
152+
}
153+
return out;
154+
}
155+
156+
function scheduleWave() {
157+
if (!gridTiling || !gridSweep) return;
158+
const points = computeGridPoints(gridTiling);
159+
let maxRow = 0, maxCol = 0;
160+
for (const p of points) {
161+
if (p.j > maxRow) maxRow = p.j;
162+
if (p.i > maxCol) maxCol = p.i;
163+
}
164+
for (const p of points) {
165+
let frac: number;
166+
if (gridSweep === 'rows') frac = maxRow > 0 ? p.j / maxRow : 0;
167+
else if (gridSweep === 'cols') frac = maxCol > 0 ? p.i / maxCol : 0;
168+
else frac = (maxRow + maxCol) > 0 ? (p.i + p.j) / (maxRow + maxCol) : 0;
169+
schedule.push({
170+
x: p.x, y: p.y,
171+
at: t + frac * SWEEP_DURATION,
172+
maxRRatio: GRID_RIPPLE_RATIO,
173+
});
174+
}
175+
}
176+
177+
function applyCue(next: string) {
178+
// Size modifiers are pure parameter tweaks — they don't change the
179+
// active cue or clear schedules. Affect the NEXT ripple only.
180+
if (next === 'small') { sizeMultiplier = 0.55; return; }
181+
if (next === 'large') { sizeMultiplier = 1.55; return; }
182+
183+
// Everything below is a cue transition. Clear pending grid ripples
184+
// so a previous wave doesn't bleed into the new state.
185+
const wasDrop = cue === 'drop';
186+
schedule.length = 0;
187+
gridTiling = null;
188+
gridSweep = null;
189+
nextWaveAt = Infinity;
190+
191+
// Grid-wave cue — format: "<sq|hex>-<rows|cols|diag>"
192+
const m = next.match(/^(sq|hex)-(rows|cols|diag)$/);
193+
if (m) {
194+
gridTiling = m[1] as Tiling;
195+
gridSweep = m[2] as Sweep;
196+
cue = next;
197+
nextSpawnAt = Infinity; // grid overrides random auto-spawn
198+
nextWaveAt = t + 20; // fire first wave almost immediately
199+
return;
200+
}
201+
202+
// Simple cue (empty / drop / many / still). Unknown cue names are
203+
// swallowed — keeps author typos from blowing up the stage.
204+
if (!(next in CUE)) return;
205+
cue = next;
206+
const g = CUE[next as SimpleCue].gap;
207+
nextSpawnAt = g ? t + g.min * 0.4 : Infinity;
208+
209+
// Entering `drop` plants one strong centered ripple — that's the
210+
// whole point of this state. Guards:
211+
// - wasDrop: re-firing the same cue doesn't re-trigger.
212+
// - lastDropAt: bouncing through drop quickly won't stack rings
213+
// on a ripple that's still alive.
214+
if (next === 'drop' && !wasDrop && t - lastDropAt > LIFETIME) {
215+
spawn(w * 0.5, h * 0.5, 1.5, CUE.drop.maxR);
216+
lastDropAt = t;
217+
}
218+
}
219+
220+
const onCue = (e: Event) => {
221+
const d = (e as CustomEvent).detail as { cue?: string } | undefined;
222+
if (typeof d?.cue === 'string') applyCue(d.cue);
223+
};
224+
const onPointerDown = (e: PointerEvent) => {
225+
const rect = canvas!.getBoundingClientRect();
226+
// Pointer drops take the current cue's radius — they feel like
227+
// they belong to whatever mood the canvas is in. For grid cues we
228+
// use the smaller grid-ripple size so a click doesn't overwhelm
229+
// the ongoing wave.
230+
const maxR = (cue in CUE) ? CUE[cue as SimpleCue].maxR : GRID_RIPPLE_RATIO;
231+
spawn(e.clientX - rect.left, e.clientY - rect.top, 1.1, maxR);
232+
};
233+
const onPause = (e: Event) => {
234+
paused = !!(e as CustomEvent).detail?.paused;
235+
};
236+
237+
window.addEventListener('stage:cue', onCue);
238+
window.addEventListener('stage:pause', onPause);
239+
window.addEventListener('resize', resize);
240+
canvas.addEventListener('pointerdown', onPointerDown);
241+
242+
function easeOut(u: number) { return 1 - Math.pow(1 - u, 2.2); }
243+
244+
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
245+
let rafId = 0;
246+
let aborted = false;
247+
248+
function drawRipples() {
249+
ctx.fillStyle = '#0f0f10';
250+
ctx.fillRect(0, 0, w, h);
251+
252+
ctx.strokeStyle = '#e6e2d6';
253+
ctx.lineWidth = 0.7;
254+
for (let i = ripples.length - 1; i >= 0; i--) {
255+
const r = ripples[i];
256+
const baseAge = (t - r.born) / LIFETIME;
257+
if (baseAge >= EXPIRE_AT) {
258+
ripples.splice(i, 1);
259+
continue;
260+
}
261+
// Use the ripple's OWN maxR, captured at spawn — see Ripple type.
262+
for (let k = 0; k < RING_COUNT; k++) {
263+
const ringAge = baseAge - k * RING_STAGGER;
264+
if (ringAge <= 0 || ringAge >= 1) continue;
265+
const eased = easeOut(ringAge);
266+
const radius = eased * r.maxR * r.strength;
267+
const alpha = Math.sin(ringAge * Math.PI) * 0.34 * (1 - k * 0.18) * r.strength;
268+
ctx.globalAlpha = alpha;
269+
ctx.beginPath();
270+
ctx.arc(r.cx, r.cy, radius, 0, Math.PI * 2);
271+
ctx.stroke();
272+
}
273+
}
274+
ctx.globalAlpha = 1;
275+
}
276+
277+
function frame() {
278+
if (aborted) return;
279+
rafId = requestAnimationFrame(frame);
280+
281+
const now = performance.now();
282+
let dt = now - lastNow;
283+
if (dt > 100) dt = 100; // tab-return guard, same reason as LobbySketch
284+
lastNow = now;
285+
if (!paused) t += dt;
286+
287+
// Grid-wave scheduler: while a grid cue is active, enqueue a fresh
288+
// wave every WAVE_INTERVAL. The wave itself takes SWEEP_DURATION
289+
// to finish firing; the gap between the END of one wave and the
290+
// start of the next is ~(WAVE_INTERVAL - SWEEP_DURATION).
291+
if (!paused && gridTiling && gridSweep && t >= nextWaveAt) {
292+
scheduleWave();
293+
nextWaveAt = t + WAVE_INTERVAL;
294+
}
295+
296+
// Drain scheduled grid ripples whose fire time has arrived.
297+
for (let i = schedule.length - 1; i >= 0; i--) {
298+
if (schedule[i].at <= t) {
299+
const s = schedule[i];
300+
spawn(s.x, s.y, 0.9, s.maxRRatio);
301+
schedule.splice(i, 1);
302+
}
303+
}
304+
305+
// Simple-cue random auto-spawn (many / still). The while-loop
306+
// catches up if we were tab-backgrounded — but note the dt clamp
307+
// already prevents a huge burst here.
308+
while (!paused && t >= nextSpawnAt) {
309+
if (!(cue in CUE)) { nextSpawnAt = Infinity; break; }
310+
const g = CUE[cue as SimpleCue].gap;
311+
if (!g) { nextSpawnAt = Infinity; break; }
312+
// Capture cue params on the ripple at birth — if cue flips before
313+
// this ripple dies, it keeps the shape it was born into.
314+
spawn(
315+
Math.random() * w,
316+
Math.random() * h,
317+
CUE[cue as SimpleCue].strength,
318+
CUE[cue as SimpleCue].maxR,
319+
);
320+
nextSpawnAt = t + g.min + Math.random() * g.span;
321+
}
322+
323+
drawRipples();
324+
}
325+
326+
if (reduced) {
327+
// Render a single still frame of a couple mid-life ripples and stop.
328+
canvas.removeEventListener('pointerdown', onPointerDown);
329+
ctx.fillStyle = '#0f0f10';
330+
ctx.fillRect(0, 0, w, h);
331+
ctx.strokeStyle = '#e6e2d6';
332+
ctx.lineWidth = 0.7;
333+
const maxR = Math.min(w, h) * 0.22;
334+
const frozen = [
335+
{ cx: w * 0.5, cy: h * 0.5, age: 0.35 },
336+
{ cx: w * 0.32, cy: h * 0.62, age: 0.6 },
337+
];
338+
for (const r of frozen) {
339+
for (let k = 0; k < RING_COUNT; k++) {
340+
const ringAge = r.age - k * RING_STAGGER;
341+
if (ringAge <= 0 || ringAge >= 1) continue;
342+
const eased = easeOut(ringAge);
343+
ctx.globalAlpha = Math.sin(ringAge * Math.PI) * 0.34 * (1 - k * 0.18);
344+
ctx.beginPath();
345+
ctx.arc(r.cx, r.cy, eased * maxR, 0, Math.PI * 2);
346+
ctx.stroke();
347+
}
348+
}
349+
ctx.globalAlpha = 1;
350+
currentCleanup = () => {
351+
aborted = true;
352+
window.removeEventListener('stage:cue', onCue);
353+
window.removeEventListener('stage:pause', onPause);
354+
window.removeEventListener('resize', resize);
355+
currentCleanup = null;
356+
};
357+
document.addEventListener('astro:before-swap', () => currentCleanup?.(), { once: true });
358+
return;
359+
}
360+
361+
frame();
362+
363+
currentCleanup = () => {
364+
aborted = true;
365+
cancelAnimationFrame(rafId);
366+
window.removeEventListener('stage:cue', onCue);
367+
window.removeEventListener('stage:pause', onPause);
368+
window.removeEventListener('resize', resize);
369+
currentCleanup = null;
370+
};
371+
document.addEventListener('astro:before-swap', () => currentCleanup?.(), { once: true });
372+
}
373+
374+
init();
375+
document.addEventListener('astro:page-load', init);
376+
</script>
377+
378+
<style>
379+
canvas {
380+
display: block;
381+
width: 100%;
382+
height: 100%;
383+
background: var(--bg);
384+
touch-action: none;
385+
}
386+
</style>

0 commit comments

Comments
 (0)