/* The sheet wraps around a cylinder sitting on the fold line. */
export const sheetVS = `
attribute vec2 a; uniform vec2 S, n; uniform float L, R; varying vec2 p;
const float PI = 3.14159265;
void main() {
p = a * S;
float pn = dot(p, n), d = pn - L, nc = pn, z = 0.;
vec2 t = p - n * pn;
if (d > 0.) {
if (d < PI * R) { float b = d / R; nc = L + R * sin(b); z = R * (1. - cos(b)); }
else { nc = L - (d - PI * R); z = 2. * R; }
}
vec2 q = t + n * nc;
q = S * .5 + (q - S * .5) * (1. + z / 1800.);
vec2 c = q / S * 2. - 1.;
gl_Position = vec4(c.x, -c.y, -z / (4. * max(S.x, S.y)), 1.);
}`;
export const sheetFS = `
precision highp float;
varying vec2 p; uniform sampler2D F, B; uniform vec2 S, n; uniform float L, R, Roll;
const float PI = 3.14159265;
void main() {
float d = dot(p, n) - L;
float a = d / R;
vec2 uv = p / S;
vec3 c;
if (a < PI * .5) {
c = texture2D(F, uv).rgb;
if (a > 0.) {
float x = a / (PI * .5);
c *= 1. - .34 * x * x;
float g = (x - .42) / .2;
c += .09 * exp(-g * g);
} else {
vec2 src = p + vec2(10., 14.) + n * (PI * R - 2. * d);
vec2 e = abs(src - S * .5) - S * .5;
float sd = max(e.x, e.y);
float sh = (1. - smoothstep(-12., 34., sd)) * .36;
sh = max(sh, .3 * exp(d / (R * 1.1 + 6.)));
// The flat page is the real DOM underneath, so only its shadow is drawn here.
gl_FragColor = vec4(0., 0., 0., sh);
return;
}
} else {
c = texture2D(B, vec2(1. - uv.x, uv.y)).rgb;
float x = clamp((a - PI * .5) / (PI * .5), 0., 1.);
float light = mix(.55, 1.18, sin(x * PI * .5));
if (a > PI) light = mix(1.18, 1.02, clamp((a - PI) / 3., 0., 1.));
c = c * light + .045;
// Curled up, the outside of the sheet is plain white paper.
vec3 paper = vec3(.984, .988, .996) * mix(.62, 1.03, sin(x * PI));
c = mix(c, paper, Roll);
}
gl_FragColor = vec4(c, 1.);
}`;
export const shadowVS = `
attribute vec2 a; uniform vec2 S; varying vec2 p;
void main() { p = a * S; vec2 c = a * 2. - 1.; gl_Position = vec4(c.x, -c.y, .99, 1.); }`;
export const shadowFS = `
precision highp float; varying vec2 p; uniform vec2 n; uniform float L, R;
void main() {
float d = dot(p, n) - L;
if (d < 0.) discard;
gl_FragColor = vec4(0., 0., 0., .6 * exp(-max(d - R * .8, 0.) / (R * 1.6 + 14.)));
}`;
import { paintElement, paintSteps } from "./paint";
import { shadowFS, shadowVS, sheetFS, sheetVS } from "./shaders";
import { commitVisit, fillVisit, noteLetGo, resetVisit, watchVisit } from "./visit";
export type PeelElements = {
stage: HTMLElement;
front: HTMLElement;
back: HTMLElement;
canvas: HTMLCanvasElement;
grab: HTMLElement;
returnGrab: HTMLElement;
};
type Vec = { x: number; y: number };
type Fold = { n: Vec; R: number; L: number; dist: number; roll: number };
type State = "flat" | "rest" | "drag" | "out" | "in" | "flipped" | "dragBack";
type Program = { program: WebGLProgram; u: Record<string, WebGLUniformLocation | null> };
const PI = Math.PI;
const ROLL_RADIUS = 48; // Thickness of the curled corner left after peeling.
const PARK_FOLD = 10; // Where the fold rests, measured along the 45-degree line from the top-left corner.
const DIAGONAL = Math.SQRT1_2;
const FOLD_OVERLAP = 1; // The page tucks this far under the curl so no seam shows.
/* The hero's back reads the visit and shows a message; other sheets just peel. */
type Hooks = {
watch: (signal: AbortSignal) => void;
fill: (back: HTMLElement) => void;
commit: (back: HTMLElement) => void;
reset: () => void;
letGo: () => void;
};
const VISIT_HOOKS: Hooks = { watch: watchVisit, fill: fillVisit, commit: commitVisit, reset: resetVisit, letGo: noteLetGo };
const QUIET_HOOKS: Hooks = { watch: () => {}, fill: () => {}, commit: () => {}, reset: () => {}, letGo: () => {} };
/* Attaches the corner peel to a sheet and returns a function that removes it. */
export function createPeel(elements: PeelElements, { visit = true }: { visit?: boolean } = {}): () => void {
const hooks = visit ? VISIT_HOOKS : QUIET_HOOKS;
const gl = elements.canvas.getContext("webgl", {
alpha: true,
premultipliedAlpha: true,
antialias: true,
depth: true,
});
return gl ? runPeel(gl, elements, hooks) : runWithoutWebGL(elements, hooks);
}
function setSide({ stage, front, back }: PeelElements, side: "front" | "back") {
stage.dataset.side = side;
front.inert = side === "back";
back.inert = side === "front";
}
function runWithoutWebGL(elements: PeelElements, hooks: Hooks) {
const { front, back, grab, returnGrab } = elements;
const abort = new AbortController();
hooks.watch(abort.signal);
hooks.fill(back);
const turn = (peeled: boolean) => {
front.style.visibility = peeled ? "hidden" : "";
grab.hidden = peeled;
returnGrab.hidden = !peeled;
if (peeled) hooks.commit(back);
back.toggleAttribute("data-visit-shown", peeled);
if (!peeled) hooks.reset();
setSide(elements, peeled ? "back" : "front");
(peeled ? returnGrab : grab).focus({ preventScroll: true });
};
grab.addEventListener("click", () => turn(true), { signal: abort.signal });
returnGrab.addEventListener("click", () => turn(false), { signal: abort.signal });
return () => abort.abort();
}
function runPeel(gl: WebGLRenderingContext, elements: PeelElements, hooks: Hooks) {
const { stage, front, back, canvas, grab, returnGrab } = elements;
const abort = new AbortController();
const { signal } = abort;
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const springs: Record<string, [number, number]> = {
rest: [120, 9],
drag: [280, 30],
dragBack: [280, 30],
out: reduced ? [800, 60] : [46, 11],
in: reduced ? [800, 60] : [46, 13],
};
hooks.watch(signal);
const LAY_DOWN_SPRING = springs.in;
const sheet = createProgram(gl, sheetVS, sheetFS);
const shadow = createProgram(gl, shadowVS, shadowFS);
const frontTexture = createTexture(gl);
const backTexture = createTexture(gl);
const quad = gl.createBuffer();
const meshVertices = gl.createBuffer();
const meshIndices = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW);
let width = 0;
let height = 0;
let corner: Vec = { x: 0, y: 0 };
let restPoint: Vec = { x: 0, y: 0 };
let hoverPoint: Vec = { x: 0, y: 0 };
let liftPoint: Vec = { x: 0, y: 0 };
let parkPoint: Vec = { x: 0, y: 0 };
let parkHoverPoint: Vec = { x: 0, y: 0 };
let parkFold = 0;
let peeled = false;
let nudged = false;
let textureScale = 1;
let dragStart: Vec = { x: 0, y: 0 };
let maxRadius = 40;
let indexCount = 0;
let position: Vec = { x: 0, y: 0 };
let velocity: Vec = { x: 0, y: 0 };
let target: Vec = { x: 0, y: 0 };
let state: State = "flat";
let running = false;
let lastFrame = 0;
let frameId = 0;
let started = false;
let fontsReady = false;
let grabOffset: Vec = { x: 0, y: 0 };
let pointer = { x: 0, y: 0, t: 0 };
let previousPointer = pointer;
let overGrab = false;
let leaving = false; // Laying down because the visitor is heading to another page.
const timers = new Set<ReturnType<typeof setTimeout>>();
const foldAngle = back.querySelector("[data-fold-angle]");
const foldRadius = back.querySelector("[data-fold-radius]");
const later = (fn: () => void, ms: number) => {
const id = setTimeout(() => {
timers.delete(id);
fn();
}, ms);
timers.add(id);
};
const alongDiagonal = (point: Vec) => (point.x + point.y) * DIAGONAL;
/* The corner position that curls the sheet up with its fold at distance L from the top-left corner. */
const pointForFold = (L: number): Vec => {
const dist = Math.max(40, 2 * (alongDiagonal(corner) - (PI * ROLL_RADIUS) / 2 - L));
return { x: corner.x - DIAGONAL * dist, y: corner.y - DIAGONAL * dist };
};
const fold = (point: Vec): Fold | null => {
const vx = corner.x - point.x;
const vy = corner.y - point.y;
const dist = Math.hypot(vx, vy);
if (dist < 0.5) return null;
const n = { x: vx / dist, y: vy / dist };
const parked = 2 * (alongDiagonal(corner) - (PI * ROLL_RADIUS) / 2 - PARK_FOLD);
const nearing = Math.max(0, Math.min(1, (dist - parked * 0.82) / (parked * 0.18)));
const level = Math.max(0, Math.min(1, 1 - Math.abs(n.x - DIAGONAL) / 0.12));
const roll = nearing * level;
const free = Math.min(maxRadius, Math.max(2, dist * 0.16)); // Small pulls make a tight curl.
const R = free + (ROLL_RADIUS - free) * roll;
const L = (point.x * n.x + point.y * n.y + (corner.x * n.x + corner.y * n.y) - PI * R) / 2;
return { n, R, L, dist, roll };
};
/* The part of the page rectangle where dot(p, n) - L is below limit. */
const clipPolygon = (f: Fold, limit: number) => {
const distance = (q: Vec) => q.x * f.n.x + q.y * f.n.y - f.L - limit;
const rect = [
{ x: 0, y: 0 },
{ x: width, y: 0 },
{ x: width, y: height },
{ x: 0, y: height },
];
const out: Vec[] = [];
rect.forEach((a, i) => {
const b = rect[(i + 1) % 4];
const da = distance(a);
const db = distance(b);
if (da < 0) out.push(a);
if (da * db < 0) {
const t = da / (da - db);
out.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
}
});
return out.map((q) => `${q.x.toFixed(1)}px ${q.y.toFixed(1)}px`).join(",");
};
const buildMesh = (cols: number, rows: number) => {
const vertices = new Float32Array((cols + 1) * (rows + 1) * 2);
let k = 0;
for (let y = 0; y <= rows; y++) {
for (let x = 0; x <= cols; x++) {
vertices[k++] = x / cols;
vertices[k++] = y / rows;
}
}
const indices = new Uint16Array(cols * rows * 6);
k = 0;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const a = y * (cols + 1) + x;
const b = a + 1;
const c = a + cols + 1;
const d = c + 1;
indices.set([a, b, c, b, d, c], k);
k += 6;
}
}
gl.bindBuffer(gl.ARRAY_BUFFER, meshVertices);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, meshIndices);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
indexCount = indices.length;
};
const draw = () => {
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
const f = fold(position);
if (!f) {
front.style.clipPath = "";
return;
}
/* The real page runs right up to the fold. WebGL only draws the curl, the flap and shadows. */
const kept = clipPolygon(f, FOLD_OVERLAP);
front.style.clipPath = kept ? `polygon(${kept})` : "polygon(0 0)";
if (foldAngle) foldAngle.textContent = `${((Math.atan2(f.n.y, f.n.x) * 180) / PI).toFixed(1)}°`;
if (foldRadius) foldRadius.textContent = `${Math.round(f.R)} px`;
gl.disable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(shadow.program);
gl.uniform2f(shadow.u.S, width, height);
gl.uniform2f(shadow.u.n, f.n.x, f.n.y);
gl.uniform1f(shadow.u.L, f.L);
gl.uniform1f(shadow.u.R, f.R);
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
gl.useProgram(sheet.program);
gl.uniform2f(sheet.u.S, width, height);
gl.uniform2f(sheet.u.n, f.n.x, f.n.y);
gl.uniform1f(sheet.u.L, f.L);
gl.uniform1f(sheet.u.R, f.R);
gl.uniform1f(sheet.u.Roll, f.roll);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, frontTexture);
gl.uniform1i(sheet.u.F, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, backTexture);
gl.uniform1i(sheet.u.B, 1);
gl.bindBuffer(gl.ARRAY_BUFFER, meshVertices);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, meshIndices);
gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0);
};
const settle = () => {
running = false;
draw();
};
/* Runs as soon as most of the sheet is off, so the blueprint is ready before it settles. */
const arrive = () => {
if (peeled) return;
peeled = true;
hooks.commit(back);
back.toggleAttribute("data-visit-shown", true);
setSide(elements, "back");
};
/* The sheet stays on screen as a white curl in the top-left corner, ready to pull back. */
const finishPeel = () => {
const keyboard = document.activeElement === grab;
running = false;
state = "flipped";
grab.hidden = true;
returnGrab.hidden = false;
overGrab = false;
arrive();
if (keyboard) returnGrab.focus({ preventScroll: true });
draw();
scheduleNudge();
};
/* A few seconds after arriving, the curl loosens once to show it can be pulled back. */
const scheduleNudge = () => {
if (nudged || reduced) return;
nudged = true;
later(() => {
if (state !== "flipped") return;
target = parkHoverPoint;
state = "out";
go();
later(() => {
if (state === "out") target = parkPoint;
}, 480);
}, 3200);
};
const step = (dt: number) => {
const [k, c] = springs[state] ?? springs.rest;
const substeps = Math.max(1, Math.ceil(dt * 120));
const h = dt / substeps;
for (let i = 0; i < substeps; i++) {
velocity.x += (k * (target.x - position.x) - c * velocity.x) * h;
velocity.y += (k * (target.y - position.y) - c * velocity.y) * h;
position.x += velocity.x * h;
position.y += velocity.y * h;
}
position.x = Math.min(position.x, corner.x);
position.y = Math.min(position.y, corner.y);
};
const frame = (now: number) => {
if (!running) return;
const dt = Math.min((now - lastFrame) / 1000, 1 / 20);
lastFrame = now;
step(dt);
draw();
if (state === "out" && !peeled) {
const f = fold(position);
if (f && f.L < alongDiagonal(corner) * 0.2) arrive();
}
const gap = Math.hypot(position.x - target.x, position.y - target.y);
const speed = Math.hypot(velocity.x, velocity.y);
/* Heading elsewhere: go as soon as the sheet looks down, not when the spring fully stops. */
if (state === "in" && leaving && gap < 5) {
leaving = false;
stage.dispatchEvent(new Event("peel:rest"));
}
if (state === "out" && gap < 1.5 && speed < 24) {
position = { ...target };
velocity = { x: 0, y: 0 };
finishPeel();
return;
}
const settled = gap < 0.3 && speed < 4;
if ((state === "rest" || state === "in") && settled) {
const wasLayingDown = state === "in";
state = "rest";
position = { ...target };
velocity = { x: 0, y: 0 };
settle();
if (wasLayingDown) {
springs.in = LAY_DOWN_SPRING;
if (leaving) stage.dispatchEvent(new Event("peel:rest"));
leaving = false;
}
return;
}
frameId = requestAnimationFrame(frame);
};
const go = () => {
if (running) return;
running = true;
lastFrame = performance.now();
frameId = requestAnimationFrame(frame);
};
/*
* Painting the sheet means measuring every word on it, so it only happens
* when something on the sheet has changed since the last time.
*/
/*
* Painting a side means measuring every word on it, so each side is only
* painted again when something on it has changed. A new message on the back
* leaves the front alone.
*/
const stale = { front: true, back: true };
const watched: MutationObserverInit = { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["class", "hidden", "data-visit-shown"] };
const frontChanges = new MutationObserver(() => (stale.front = true));
const backChanges = new MutationObserver(() => (stale.back = true));
frontChanges.observe(front, watched);
backChanges.observe(back, watched);
let backJob = 0; // A slow paint of the back in progress; any other paint of it cancels this one.
const paintSide = (side: "front" | "back") => {
if (side === "back") backJob++;
const [texture, root, unit, changes] = side === "front" ? [frontTexture, front, gl.TEXTURE0, frontChanges] : [backTexture, back, gl.TEXTURE1, backChanges];
/* Measured as laid out, even while the page is mid-flight and scaled. */
const moved: [HTMLElement, string][] = [];
for (let node = stage.parentElement; node; node = node.parentElement) {
if (node.style.transform) {
moved.push([node, node.style.transform]);
node.style.transform = "";
}
}
gl.activeTexture(unit);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, paintElement(root, width, height, textureScale));
moved.forEach(([node, transform]) => { node.style.transform = transform; });
changes.takeRecords();
stale[side] = false;
};
const paintTextures = (force = false) => {
hooks.fill(back);
if (frontChanges.takeRecords().length) stale.front = true;
if (backChanges.takeRecords().length) stale.back = true;
if (force || stale.front) paintSide("front");
if (force || stale.back) paintSide("back");
};
/* Painted while the cursor rests on the corner, so taking hold of it never has to wait. */
const idle: (run: (deadline?: IdleDeadline) => void, options?: IdleRequestOptions) => void =
window.requestIdleCallback?.bind(window) ?? ((run) => window.setTimeout(run, 1));
/*
* The back, painted a slice at a time in the gaps between frames: the first
* time it is measured it is slow (the blueprint carries thousands of words),
* and nobody sees it until the corner lifts.
*/
const paintBackSlowly = () => {
const job = ++backJob;
hooks.fill(back);
backChanges.takeRecords();
const steps = paintSteps(back, width, height, textureScale);
const work = (deadline?: IdleDeadline) => {
if (job !== backJob || signal.aborted) return;
/* Mid-flight the page is moved, and measuring it then would put everything in the wrong place. */
if (document.documentElement.hasAttribute("data-travelling")) {
stale.back = true;
return;
}
const until = performance.now() + Math.max(deadline?.timeRemaining() ?? 0, deadline?.didTimeout ? 8 : 4);
while (performance.now() < until) {
const step = steps.next();
if (!step.done) continue;
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, backTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, step.value);
/* Anything that changed on the back while this ran is painted again later. */
stale.back = backChanges.takeRecords().length > 0;
/* Only redraw a sheet that is already curled. A flat one has no corner position yet, and drawing it would flash a full peel. */
if (!running && state !== "flat") draw();
return;
}
idle(work, { timeout: 400 });
};
idle(work, { timeout: 400 });
};
const paintSoon = () => idle(() => { if (!signal.aborted && started) paintTextures(); }, { timeout: 120 });
const layout = () => {
width = stage.clientWidth;
height = stage.clientHeight;
corner = { x: width, y: height };
const unit = Math.min(width, height);
maxRadius = Math.max(24, Math.min(68, unit * 0.1));
restPoint = { x: width - unit * 0.07, y: height - unit * 0.095 };
hoverPoint = { x: width - unit * 0.1, y: height - unit * 0.13 };
liftPoint = { x: width - unit * 0.24, y: height - unit * 0.33 };
parkFold = PARK_FOLD;
parkPoint = pointForFold(parkFold);
parkHoverPoint = pointForFold(parkFold + 24);
textureScale = Math.min(devicePixelRatio || 1, 2.5, 4096 / Math.max(width, height));
canvas.width = Math.round(width * textureScale);
canvas.height = Math.round(height * textureScale);
gl.viewport(0, 0, canvas.width, canvas.height);
buildMesh(width > height ? 170 : 110, width > height ? 110 : 170);
/* The front first; the back, which only shows once the corner lifts, while the page is idle. */
stale.back = true;
paintSide("front");
if (document.documentElement.hasAttribute("data-travelling") || matchMedia("(prefers-reduced-motion: reduce)").matches) paintSide("back");
else paintBackSlowly();
};
const restNow = () => {
position = { ...restPoint };
target = { ...restPoint };
velocity = { x: 0, y: 0 };
state = "rest";
settle();
};
const start = () => {
if (started || !fontsReady || !stage.clientWidth || !stage.clientHeight) return;
started = true;
layout();
/* Arriving from another page: the corner is already hanging, as it was when the visitor left. */
if (reduced || document.documentElement.hasAttribute("data-travelling")) {
restNow();
return;
}
later(() => {
if (state !== "flat") return;
position = { ...corner };
velocity = { x: 0, y: 0 };
target = liftPoint;
state = "rest";
go();
later(() => {
if (state === "rest") target = restPoint;
}, 520);
}, 1500);
};
const stagePoint = (event: PointerEvent): Vec => {
const rect = stage.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
};
const clampToSheet = (q: Vec): Vec => ({
x: Math.min(corner.x - 1, Math.max(-width * 0.5, q.x)),
y: Math.min(corner.y - 1, Math.max(-height * 0.5, q.y)),
});
grab.addEventListener(
"pointerenter",
(event) => {
overGrab = true;
paintSoon();
if (event.pointerType === "mouse" && state === "rest") {
target = hoverPoint;
go();
}
},
{ signal },
);
grab.addEventListener(
"pointerleave",
(event) => {
overGrab = false;
if (event.pointerType === "mouse" && state === "rest") {
target = restPoint;
go();
}
},
{ signal },
);
grab.addEventListener(
"pointerdown",
(event) => {
if (!started || state === "flipped" || state === "out" || state === "in") return;
grab.setPointerCapture(event.pointerId);
paintTextures(); // Only if the page has changed since it was last copied.
state = "drag";
const point = stagePoint(event);
grabOffset = { x: position.x - point.x, y: position.y - point.y };
pointer = previousPointer = { ...point, t: event.timeStamp };
target = { ...position };
go();
},
{ signal },
);
grab.addEventListener(
"pointermove",
(event) => {
if (state !== "drag") return;
const point = stagePoint(event);
previousPointer = pointer;
pointer = { ...point, t: event.timeStamp };
target = clampToSheet({ x: point.x + grabOffset.x, y: point.y + grabOffset.y });
},
{ signal },
);
const release = () => {
if (state !== "drag") return;
const f = fold(position);
const elapsed = Math.max(1, pointer.t - previousPointer.t);
const fling = f
? -((pointer.x - previousPointer.x) * f.n.x + (pointer.y - previousPointer.y) * f.n.y) / elapsed
: 0;
if (f && (f.dist > Math.hypot(corner.x, corner.y) * 0.45 || (fling > 1.1 && f.dist > 90))) {
state = "out";
target = parkPoint;
} else {
hooks.letGo();
state = "rest";
target = restPoint;
}
};
grab.addEventListener("pointerup", release, { signal });
grab.addEventListener("pointercancel", release, { signal });
/* The corner leans toward a mouse as it gets close, so people find the peel. */
stage.addEventListener(
"pointermove",
(event) => {
if (reduced || event.pointerType !== "mouse" || state !== "rest" || overGrab) return;
const point = stagePoint(event);
const reach = Math.min(width, height) * 0.42;
const closeness = Math.max(0, 1 - Math.hypot(corner.x - point.x, corner.y - point.y) / reach);
const lean = closeness * closeness * 0.6;
const next = {
x: restPoint.x + (hoverPoint.x - restPoint.x) * lean,
y: restPoint.y + (hoverPoint.y - restPoint.y) * lean,
};
if (Math.hypot(next.x - target.x, next.y - target.y) < 1) return;
target = next;
go();
},
{ signal, passive: true },
);
stage.addEventListener(
"pointerleave",
() => {
if (state === "rest" && target !== restPoint) {
target = restPoint;
go();
}
},
{ signal },
);
/* The curled corner leans toward a mouse that comes near it. */
stage.addEventListener(
"pointermove",
(event) => {
if (reduced || event.pointerType !== "mouse" || state !== "flipped") return;
const point = stagePoint(event);
const closeness = Math.max(0, 1 - Math.hypot(point.x, point.y) / 260);
const next = pointForFold(parkFold + 24 * closeness * closeness);
if (Math.hypot(next.x - target.x, next.y - target.y) < 1) return;
target = next;
state = "out";
go();
},
{ signal, passive: true },
);
const peelOff = () => {
if (!started || (state !== "rest" && state !== "flat")) return;
paintTextures();
state = "out";
target = parkPoint;
go();
};
const layBack = () => {
const keyboard = document.activeElement === returnGrab;
peeled = false;
nudged = false;
hooks.reset();
back.toggleAttribute("data-visit-shown", false);
returnGrab.hidden = true;
grab.hidden = false;
setSide(elements, "front");
target = restPoint;
state = "in";
go();
if (keyboard) grab.focus({ preventScroll: true });
};
returnGrab.addEventListener(
"pointerenter",
(event) => {
if (event.pointerType === "mouse" && state === "flipped") {
target = parkHoverPoint;
state = "out";
go();
}
},
{ signal },
);
returnGrab.addEventListener(
"pointerleave",
(event) => {
if (event.pointerType === "mouse" && state === "out") {
target = parkPoint;
go();
}
},
{ signal },
);
returnGrab.addEventListener(
"pointerdown",
(event) => {
if (state !== "flipped" && state !== "out") return;
returnGrab.setPointerCapture(event.pointerId);
const point = stagePoint(event);
const f = fold(position);
state = "dragBack";
dragStart = point;
grabOffset = { x: alongDiagonal(point) - (f?.L ?? parkFold), y: 0 };
target = { ...position };
go();
},
{ signal },
);
returnGrab.addEventListener(
"pointermove",
(event) => {
if (state !== "dragBack") return;
const along = alongDiagonal(stagePoint(event)) - grabOffset.x;
target = pointForFold(Math.max(parkFold, Math.min(alongDiagonal(corner) * 0.96, along)));
},
{ signal },
);
const releaseBack = (event: PointerEvent) => {
if (state !== "dragBack") return;
const point = stagePoint(event);
const moved = Math.hypot(point.x - dragStart.x, point.y - dragStart.y);
const f = fold(position);
if (moved < 4 || (f && f.L > alongDiagonal(corner) * 0.45)) {
layBack();
} else {
state = "out";
target = parkPoint;
}
};
returnGrab.addEventListener("pointerup", releaseBack, { signal });
returnGrab.addEventListener("pointercancel", releaseBack, { signal });
/* Leaving the page while peeled: the sheet lays itself down quickly, then reports back. */
stage.addEventListener(
"peel:lay-down",
() => {
if (state === "flipped" || state === "out" || state === "dragBack") {
springs.in = reduced ? LAY_DOWN_SPRING : [150, 23];
leaving = true;
layBack();
} else {
stage.dispatchEvent(new Event("peel:rest"));
}
},
{ signal },
);
/* The curl is drawn on demand so a copy of the page can be taken in the same moment. */
stage.addEventListener("peel:capture", draw, { signal });
/* Keyboard route for both corners. A mouse click has detail 1 and is ignored. */
grab.addEventListener("click", (event) => { if (event.detail === 0) peelOff(); }, { signal });
returnGrab.addEventListener("click", (event) => { if (event.detail === 0) layBack(); }, { signal });
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
const refit = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (running) {
refit(); // Wait for the sheet to stop moving rather than skipping the resize.
return;
}
if (stage.clientWidth === width && stage.clientHeight === height) return;
layout();
if (state === "flipped") {
position = { ...parkPoint };
target = { ...parkPoint };
draw();
} else {
restNow();
}
}, 180);
};
const observer = new ResizeObserver(() => {
if (started) refit();
else start();
});
observer.observe(stage);
const nameFont = getComputedStyle(front.querySelector("h1") ?? front);
Promise.all([
document.fonts.load(`${nameFont.fontWeight} ${nameFont.fontSize} ${nameFont.fontFamily}`),
document.fonts.ready,
])
.catch(() => undefined)
.then(() => {
if (signal.aborted) return;
fontsReady = true;
start();
});
return () => {
abort.abort();
observer.disconnect();
frontChanges.disconnect();
backChanges.disconnect();
clearTimeout(resizeTimer);
timers.forEach(clearTimeout);
cancelAnimationFrame(frameId);
running = false;
front.style.clipPath = "";
grab.hidden = false;
returnGrab.hidden = true;
setSide(elements, "front");
};
}
function createProgram(gl: WebGLRenderingContext, vertex: string, fragment: string): Program {
const program = gl.createProgram()!;
for (const [type, source] of [
[gl.VERTEX_SHADER, vertex],
[gl.FRAGMENT_SHADER, fragment],
] as const) {
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(shader) ?? "Shader failed to compile");
}
gl.attachShader(program, shader);
}
gl.bindAttribLocation(program, 0, "a");
gl.linkProgram(program);
const u: Program["u"] = {};
const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number;
for (let i = 0; i < count; i++) {
const { name } = gl.getActiveUniform(program, i)!;
u[name] = gl.getUniformLocation(program, name);
}
return { program, u };
}
function createTexture(gl: WebGLRenderingContext) {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return texture;
}
ARUNAN
KAVIRAJAN
KAVIRAJAN
- Drawn by
- Arunan K.
- Scale
- 1:1
- Rev
- a106f81
- Date
Fold 0.0°Curl 0 px
ARUNAN
KAVIRAJAN
ARUNAN
KAVIRAJAN
KAVIRAJAN
ARUNAN
KAVIRAJAN
KAVIRAJAN
Arunan Kavirajan, software developer and B.Tech IT student at SRM Institute of Science and Technology, building web and mobile products.
On this site
- About: how it started, experience, tools, and what I'm working on now.
- Work: 8 projects, for clients and my own.
- Contact: email, GitHub, LinkedIn and Instagram.
Projects
- CHAT Club: A cyberpunk home for a student hacker club at SRMIST: red team, blue team and advanced tech.
- Billing App: Billing and orders for a food stall, printing receipts straight to a Bluetooth thermal printer.
- Algorithm Lab: Runs real algorithms on a Python engine and replays every step, so you can watch them think.
- Echoes: An anonymous message board with no feed. Messages become constellations, grouped by month.
- Certiva: Turns a PDF template and a spreadsheet into hundreds of certificates, without leaving the browser.
- Koda: Multi-agent AI that reads an unfamiliar GitHub repository and maps how it fits together.
- Ascend: Deep work with friends keeping you honest, and rain made from maths instead of audio files.
- Pallavan MES: Factory-floor software that keeps working when the network drops, then syncs when it returns.