"use client"; import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "motion/react"; type HalftoneRevealProps = { src: string; alt: string; /** Printed under the picture. */ caption?: string; paper?: string; ink?: string; /** Dots across the picture. They never get smaller than 4.5 pixels apart. */ dots?: number; /** Size of the colour patch under the pointer, as a part of the width. */ lens?: number; /** Tilt of the dot rows, in degrees. */ angle?: number; className?: string; }; /* HalftoneReveal: a newspaper print made of ink dots. Under the pointer the dots swell open onto the real colour picture. Press to open all of it. */ export function HalftoneReveal({ src, alt, caption, paper = "#ece4d2", ink = "#191713", dots = 84, lens = 0.3, angle = 45, className = "" }: HalftoneRevealProps) { const reduce = useReducedMotion(); const pic = useRef(null); const sheet = useRef(null); const [full, setFull] = useState(false); const live = useRef({ x: 0, y: 0, toX: 0, toY: 0, open: 0, toOpen: 0, all: 0, toAll: 0 }); const wake = useRef(() => {}); useEffect(() => { const canvas = sheet.current!; const image = pic.current!; const ctx = canvas.getContext("2d"); if (!ctx) return; const s = live.current; let grid: { x: number; y: number; r: number }[] = []; let w = 0; let h = 0; let cell = 6; let raf = 0; function shades() { const cols = Math.ceil((w / cell) * 2); const rows = Math.ceil((h / cell) * 2); const off = document.createElement("canvas"); off.width = cols; off.height = rows; const o = off.getContext("2d", { willReadFrequently: true })!; const fit = Math.max(cols / image.naturalWidth, rows / image.naturalHeight); const dw = image.naturalWidth * fit; const dh = image.naturalHeight * fit; o.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh); let data: Uint8ClampedArray; try { data = o.getImageData(0, 0, cols, rows).data; } catch { return () => 0.5; // Picture from another site without CORS: an even tone. } const lum = new Float32Array(cols * rows); const count = new Float32Array(256); for (let i = 0; i < lum.length; i++) { lum[i] = (0.3 * data[i * 4] + 0.59 * data[i * 4 + 1] + 0.11 * data[i * 4 + 2]) / 255; count[Math.round(lum[i] * 255)]++; } // Spread the tones evenly, half way, so a dark photo still prints its hills. for (let i = 1; i < 256; i++) count[i] += count[i - 1]; return (x: number, y: number) => { const cx = Math.min(cols - 1, Math.max(0, Math.round((x / w) * cols))); const cy = Math.min(rows - 1, Math.max(0, Math.round((y / h) * rows))); const v = lum[cy * cols + cx]; return (v + count[Math.round(v * 255)] / lum.length) / 2; }; } function build() { if (!image.naturalWidth) return; w = canvas.clientWidth; h = canvas.clientHeight; if (!w || !h) return; const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = w * dpr; canvas.height = h * dpr; ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); cell = Math.max(4.5, w / dots); const tone = shades(); const a = (angle * Math.PI) / 180; const reach = Math.ceil(Math.hypot(w, h) / cell); grid = []; for (let u = -reach; u <= reach; u++) { for (let v = -reach; v <= reach; v++) { const x = w / 2 + (u * Math.cos(a) - v * Math.sin(a)) * cell; const y = h / 2 + (u * Math.sin(a) + v * Math.cos(a)) * cell; if (x < -cell || y < -cell || x > w + cell || y > h + cell) continue; const dark = 1 - tone(x, y); grid.push({ x, y, r: cell * 0.72 * Math.sqrt(dark) }); } } paint(); } function paint() { const c = ctx!; c.globalCompositeOperation = "source-over"; c.fillStyle = paper; c.fillRect(0, 0, w, h); c.fillStyle = ink; c.beginPath(); for (const g of grid) { if (g.r < 0.35) continue; c.moveTo(g.x + g.r, g.y); c.arc(g.x, g.y, g.r, 0, Math.PI * 2); } c.fill(); const reach = lens * w * s.open + s.all * Math.hypot(w, h) * 1.5; if (reach < 1) return; const band = reach * 0.5 + cell; c.globalCompositeOperation = "destination-out"; c.beginPath(); for (const g of grid) { const t = Math.min(1, (reach - Math.hypot(g.x - s.x, g.y - s.y)) / band); if (t <= 0) continue; const r = t * cell * 0.76; c.moveTo(g.x + r, g.y); c.arc(g.x, g.y, r, 0, Math.PI * 2); } c.fill(); } function tick() { const k = reduce ? 1 : 0.16; s.x += (s.toX - s.x) * (reduce ? 1 : 0.3); s.y += (s.toY - s.y) * (reduce ? 1 : 0.3); s.open += (s.toOpen - s.open) * k; s.all += (s.toAll - s.all) * (reduce ? 1 : 0.075); paint(); const moving = Math.abs(s.toX - s.x) + Math.abs(s.toY - s.y) > 0.3 || Math.abs(s.toOpen - s.open) > 0.004 || Math.abs(s.toAll - s.all) > 0.004; raf = moving ? requestAnimationFrame(tick) : 0; if (!moving) { s.open = s.toOpen; s.all = s.toAll; paint(); } } wake.current = () => { if (!raf) raf = requestAnimationFrame(tick); }; image.addEventListener("load", build); const watch = new ResizeObserver(build); watch.observe(canvas); build(); return () => { cancelAnimationFrame(raf); image.removeEventListener("load", build); watch.disconnect(); }; }, [paper, ink, dots, lens, angle, reduce]); function aim(e: { clientX: number; clientY: number }, jump = false) { const box = sheet.current!.getBoundingClientRect(); const s = live.current; s.toX = e.clientX - box.left; s.toY = e.clientY - box.top; if (jump) { s.x = s.toX; s.y = s.toY; } wake.current(); } function setOpen(on: boolean) { live.current.toOpen = on ? 1 : 0; wake.current(); } function press(e: { clientX: number; clientY: number; detail: number }) { const box = sheet.current!.getBoundingClientRect(); aim(e.detail ? e : { clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 }, !e.detail); live.current.toAll = full ? 0 : 1; setFull(!full); wake.current(); } return (
); }