Skip to content

Commit 1c37316

Browse files
committed
Add pretext experiment to site
1 parent 2219113 commit 1c37316

8 files changed

Lines changed: 268 additions & 1 deletion

File tree

app/components/Navbar.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export function Navbar() {
2121
const isBookmarksPage = pathname === "/bookmarks";
2222
const isBooksPage = pathname === "/books";
2323
const isWritingPage = pathname === "/writing";
24+
const isScatterPage = pathname === "/scatter";
2425
const isBlogPage = postsJson.posts.find((post: Post) => `/${post.id}` === pathname.split("#")[0]);
2526

2627
let dateText: ReactNode | null = null;
@@ -46,6 +47,16 @@ export function Navbar() {
4647
titleText = <h1>Books</h1>;
4748
} else if (isWritingPage) {
4849
titleText = <h1>Writing</h1>;
50+
} else if (isScatterPage) {
51+
titleText = null;
52+
showThemeSwitch = false;
53+
subtitleText = (
54+
<p className="text-sm text-gray-500">
55+
<Link aria-label="Link to Nathan's home page" href="/">
56+
go back
57+
</Link>
58+
</p>
59+
);
4960
} else if (isBlogPage) {
5061
const writingTitleWithId = `${isBlogPage.title} [#${isBlogPage.id}]`;
5162
titleText = getHeading(writingTitleWithId, HeadingLevel.H1);

app/components/ScatterText.tsx

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"use client";
2+
3+
import { HAMLET_ACT_1 } from "@/app/scatter/content";
4+
import { layoutWithLines, prepareWithSegments } from "@chenglou/pretext";
5+
import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
6+
7+
const graphemeSegmenter =
8+
typeof Intl !== "undefined" && "Segmenter" in Intl
9+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
10+
: null;
11+
12+
function splitGraphemes(text: string): string[] {
13+
if (!graphemeSegmenter) {
14+
return [...text];
15+
}
16+
return [...graphemeSegmenter.segment(text)].map((s) => s.segment);
17+
}
18+
19+
const INFLUENCE_RADIUS = 140;
20+
const PUSH_STRENGTH = 28;
21+
const EASE = 0.22;
22+
23+
type LineLayout = {
24+
lines: { text: string }[];
25+
lineHeightPx: number;
26+
};
27+
28+
function usePrefersReducedMotion(): boolean {
29+
const [reduced, setReduced] = useState(false);
30+
31+
useEffect(() => {
32+
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
33+
const update = () => setReduced(mq.matches);
34+
update();
35+
mq.addEventListener("change", update);
36+
return () => mq.removeEventListener("change", update);
37+
}, []);
38+
39+
return reduced;
40+
}
41+
42+
export function ScatterText() {
43+
const measureRef = useRef<HTMLDivElement>(null);
44+
const rootRef = useRef<HTMLDivElement>(null);
45+
const [layout, setLayout] = useState<LineLayout | null>(null);
46+
const reducedMotion = usePrefersReducedMotion();
47+
48+
const restCentersRef = useRef<{ x: number; y: number }[]>([]);
49+
const curRef = useRef<{ x: number; y: number }[]>([]);
50+
const mouseRef = useRef<{ x: number; y: number; active: boolean }>({ x: 0, y: 0, active: false });
51+
const rafRef = useRef(0);
52+
53+
const lineGraphemes = useMemo(() => {
54+
if (!layout) return null;
55+
return layout.lines.map((line) => {
56+
const raw = line.text || "\u00a0";
57+
const parts = splitGraphemes(raw);
58+
return parts.length > 0 ? parts : ["\u00a0"];
59+
});
60+
}, [layout]);
61+
62+
const captureRestPositions = useCallback(() => {
63+
const root = rootRef.current;
64+
if (!root) return;
65+
66+
const spans = root.querySelectorAll<HTMLElement>("[data-scatter-glyph]");
67+
const n = spans.length;
68+
if (n === 0) return;
69+
70+
for (let i = 0; i < n; i++) {
71+
spans[i].style.transform = "translate(0px, 0px)";
72+
}
73+
void root.offsetHeight;
74+
75+
const next: { x: number; y: number }[] = [];
76+
for (let i = 0; i < n; i++) {
77+
const r = spans[i].getBoundingClientRect();
78+
next.push({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
79+
}
80+
restCentersRef.current = next;
81+
curRef.current = Array.from({ length: n }, () => ({ x: 0, y: 0 }));
82+
}, []);
83+
84+
useLayoutEffect(() => {
85+
const el = measureRef.current;
86+
if (!el) return;
87+
88+
function measure() {
89+
const node = measureRef.current;
90+
if (!node) return;
91+
const width = node.clientWidth;
92+
if (width <= 0) return;
93+
94+
const cs = getComputedStyle(node);
95+
const font = cs.font;
96+
const fontSize = parseFloat(cs.fontSize) || 16;
97+
const parsedLineHeight = parseFloat(cs.lineHeight);
98+
const lineHeightPx =
99+
Number.isFinite(parsedLineHeight) && !cs.lineHeight.includes("%") ? parsedLineHeight : fontSize * 1.45;
100+
101+
const prepared = prepareWithSegments(HAMLET_ACT_1, font, { whiteSpace: "pre-wrap" });
102+
const { lines } = layoutWithLines(prepared, width, lineHeightPx);
103+
setLayout({ lines, lineHeightPx });
104+
}
105+
106+
measure();
107+
const ro = new ResizeObserver(measure);
108+
ro.observe(el);
109+
return () => ro.disconnect();
110+
}, []);
111+
112+
useLayoutEffect(() => {
113+
if (!layout || reducedMotion) return;
114+
captureRestPositions();
115+
}, [layout, reducedMotion, captureRestPositions]);
116+
117+
useEffect(() => {
118+
if (!layout || reducedMotion) return;
119+
120+
const onScroll = () => {
121+
captureRestPositions();
122+
};
123+
124+
window.addEventListener("scroll", onScroll, true);
125+
window.addEventListener("resize", captureRestPositions);
126+
return () => {
127+
window.removeEventListener("scroll", onScroll, true);
128+
window.removeEventListener("resize", captureRestPositions);
129+
};
130+
}, [layout, reducedMotion, captureRestPositions]);
131+
132+
useEffect(() => {
133+
if (!layout || reducedMotion) return;
134+
135+
function tick() {
136+
const root = rootRef.current;
137+
if (!root) {
138+
rafRef.current = requestAnimationFrame(tick);
139+
return;
140+
}
141+
142+
const spans = root.querySelectorAll<HTMLElement>("[data-scatter-glyph]");
143+
const n = spans.length;
144+
const centers = restCentersRef.current;
145+
const cur = curRef.current;
146+
const { x: mx, y: my, active } = mouseRef.current;
147+
148+
while (cur.length < n) {
149+
cur.push({ x: 0, y: 0 });
150+
}
151+
152+
for (let i = 0; i < n; i++) {
153+
const c = centers[i];
154+
let tx = 0;
155+
let ty = 0;
156+
if (active && c) {
157+
const dx = c.x - mx;
158+
const dy = c.y - my;
159+
const d = Math.hypot(dx, dy);
160+
if (d > 0.5) {
161+
const influence = Math.max(0, 1 - d / INFLUENCE_RADIUS);
162+
const f = influence * PUSH_STRENGTH;
163+
tx = (dx / d) * f;
164+
ty = (dy / d) * f;
165+
}
166+
}
167+
168+
const p = cur[i] ?? { x: 0, y: 0 };
169+
p.x += (tx - p.x) * EASE;
170+
p.y += (ty - p.y) * EASE;
171+
cur[i] = p;
172+
173+
spans[i].style.transform = `translate(${p.x.toFixed(2)}px, ${p.y.toFixed(2)}px)`;
174+
}
175+
176+
rafRef.current = requestAnimationFrame(tick);
177+
}
178+
179+
rafRef.current = requestAnimationFrame(tick);
180+
return () => cancelAnimationFrame(rafRef.current);
181+
}, [layout, reducedMotion]);
182+
183+
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
184+
if (reducedMotion) return;
185+
mouseRef.current = { x: e.clientX, y: e.clientY, active: true };
186+
};
187+
188+
const onPointerLeave = () => {
189+
mouseRef.current = { ...mouseRef.current, active: false };
190+
};
191+
192+
return (
193+
<div className="w-full min-w-0">
194+
<p className="sr-only">{HAMLET_ACT_1}</p>
195+
<div
196+
ref={measureRef}
197+
className="font-sans text-lg leading-relaxed text-foreground invisible h-0 w-full overflow-hidden pointer-events-none"
198+
aria-hidden
199+
>
200+
&nbsp;
201+
</div>
202+
<div
203+
ref={rootRef}
204+
className="whitespace-pre-wrap font-sans text-lg leading-relaxed text-foreground select-none"
205+
onPointerMove={onPointerMove}
206+
onPointerLeave={onPointerLeave}
207+
aria-hidden
208+
>
209+
{layout?.lines.map((_, lineIndex) => {
210+
const glyphs = lineGraphemes?.[lineIndex] ?? ["\u00a0"];
211+
return (
212+
<div
213+
key={lineIndex}
214+
className="flex w-full flex-nowrap items-baseline"
215+
style={{ minHeight: layout.lineHeightPx } as CSSProperties}
216+
>
217+
{glyphs.map((g, j) => (
218+
<span
219+
key={`${lineIndex}-${j}`}
220+
data-scatter-glyph
221+
className="inline-block shrink-0 will-change-transform"
222+
style={reducedMotion ? { transform: "none" } : undefined}
223+
>
224+
{g}
225+
</span>
226+
))}
227+
</div>
228+
);
229+
})}
230+
</div>
231+
</div>
232+
);
233+
}

app/scatter/content.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const HAMLET_ACT_1 =
2+
"Who’s there? Nay, answer me. Stand and unfold yourself. Long live the King! Barnardo? He. You come most carefully upon your hour. ’Tis now struck twelve. Get thee to bed, Francisco. For this relief much thanks. ’Tis bitter cold, And I am sick at heart. Have you had quiet guard? Not a mouse stirring. Well, good night. If you do meet Horatio and Marcellus, The rivals of my watch, bid them make haste. I think I hear them. Stand, ho! Who is there? Friends to this ground. And liegemen to the Dane. Give you good night. O, farewell, honest soldier, who hath reliev’d you? Barnardo has my place. Give you good-night. Holla, Barnardo! Say, what, is Horatio there? A piece of him. Welcome, Horatio. Welcome, good Marcellus. What, has this thing appear’d again tonight? I have seen nothing. Horatio says ’tis but our fantasy, And will not let belief take hold of him Touching this dreaded sight, twice seen of us. Therefore I have entreated him along With us to watch the minutes of this night, That if again this apparition come He may approve our eyes and speak to it. Tush, tush, ’twill not appear. Sit down awhile, And let us once again assail your ears, That are so fortified against our story, What we two nights have seen. Well, sit we down, And let us hear Barnardo speak of this. Last night of all, When yond same star that’s westward from the pole, Had made his course t’illume that part of heaven Where now it burns, Marcellus and myself, The bell then beating one— Peace, break thee off. Look where it comes again. In the same figure, like the King that’s dead. Thou art a scholar; speak to it, Horatio. Looks it not like the King? Mark it, Horatio. Most like. It harrows me with fear and wonder. It would be spoke to. Question it, Horatio. What art thou that usurp’st this time of night, Together with that fair and warlike form In which the majesty of buried Denmark Did sometimes march? By heaven I charge thee speak. It is offended. See, it stalks away. Stay! speak, speak! I charge thee speak! ’Tis gone, and will not answer. How now, Horatio! You tremble and look pale. Is not this something more than fantasy? What think you on’t? Before my God, I might not this believe Without the sensible and true avouch Of mine own eyes. Is it not like the King? As thou art to thyself: Such was the very armour he had on When he th’ambitious Norway combated; So frown’d he once, when in an angry parle He smote the sledded Polacks on the ice. ’Tis strange. Thus twice before, and jump at this dead hour, With martial stalk hath he gone by our watch. In what particular thought to work I know not; But in the gross and scope of my opinion, This bodes some strange eruption to our state. Good now, sit down, and tell me, he that knows, Why this same strict and most observant watch So nightly toils the subject of the land, And why such daily cast of brazen cannon And foreign mart for implements of war; Why such impress of shipwrights, whose sore task Does not divide the Sunday from the week. What might be toward, that this sweaty haste Doth make the night joint-labourer with the day: Who is’t that can inform me? That can I; At least, the whisper goes so. Our last King, Whose image even but now appear’d to us, Was, as you know, by Fortinbras of Norway, Thereto prick’d on by a most emulate pride, Dar’d to the combat; in which our valiant Hamlet, For so this side of our known world esteem’d him, Did slay this Fortinbras; who by a seal’d compact, Well ratified by law and heraldry, Did forfeit, with his life, all those his lands Which he stood seiz’d of, to the conqueror; Against the which, a moiety competent Was gaged by our King; which had return’d To the inheritance of Fortinbras, Had he been vanquisher; as by the same cov’nant And carriage of the article design’d, His fell to Hamlet." as const;

app/scatter/page.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Metadata } from "next";
2+
import { ScatterText } from "../components/ScatterText";
3+
4+
export const metadata: Metadata = {
5+
title: "Scatter",
6+
description: "Hamlet Act I laid out with Pretext; characters scatter from the cursor on hover.",
7+
};
8+
9+
export default function ScatterPage() {
10+
return (
11+
<section className="w-full max-w-4xl mx-5">
12+
<ScatterText />
13+
</section>
14+
);
15+
}

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

next.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const nextConfig = {
55
// NextJS generic configurations
66
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
77
reactStrictMode: true,
8+
transpilePackages: ["@chenglou/pretext"],
89
turbopack: [],
910
images: {
1011
qualities: [75, 100],

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"start": "next start"
2424
},
2525
"dependencies": {
26+
"@chenglou/pretext": "0.0.2",
2627
"@mdx-js/loader": "3.1.1",
2728
"@mdx-js/react": "3.1.1",
2829
"@next/mdx": "16.2.0",
@@ -40,8 +41,8 @@
4041
"react": "19.2.4",
4142
"react-dom": "19.2.4",
4243
"react-redux": "9.2.0",
43-
"redux": "5.0.1",
4444
"redis": "5.11.0",
45+
"redux": "5.0.1",
4546
"typescript": "5.9.3"
4647
},
4748
"devDependencies": {

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"esModuleInterop": true,
1010
"module": "esnext",
1111
"moduleResolution": "bundler",
12+
"allowImportingTsExtensions": true,
1213
"resolveJsonModule": true,
1314
"isolatedModules": true,
1415
"jsx": "react-jsx",

0 commit comments

Comments
 (0)