Replace static profile image with animated dot-art portrait#72
Merged
Conversation
Replace the next/image profile photo with a canvas-based "halftone" rendering. The same-origin portrait is sampled once into a grid of luma values; each cell becomes a white dot whose radius scales with local brightness, on a pure-black background. Because the portrait already sits on black, the background samples to zero-radius dots and disappears. Sampling (sampleGrid) and drawing (draw) are split so a later animation pass can re-draw against the cached grid without re-reading pixels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TDdCdTJbUkH7ZoWDhj7AV
Replace the grid sampling with a weighted Voronoi stipple (Secord 2002, the StippleGen 2 algorithm): points are seeded by importance sampling and relaxed with Lloyd's iteration toward density-weighted Voronoi centroids (Voronoi computed per-iteration via jump flooding), yielding a centroidal Voronoi tessellation whose local dot *density* tracks image brightness. The dots are now scattered organically rather than on a grid; each dot's radius also scales with brightness, and the dark background draws nothing. The point set is precomputed offline by research/profile-dots/stipple.mjs (decodes the portrait with sharp, ~40 Lloyd iterations) into public/img/profile-dots/points.json; ProfileDots.tsx just loads and draws it, so there is no per-load relaxation. Generator + README committed under research/ per the repo's "keep the figure-generation code" rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TDdCdTJbUkH7ZoWDhj7AV
Each stipple dot now keeps its precomputed spot as a "home"; every frame a spring pulls it home while a time-varying force field perturbs it, so the portrait stays recognizable but alive and loops forever: - curl-noise shimmer: a divergence-free flow field (curl of a scalar potential) gives gentle in-place swirling drift, no net drift/clumping - breathing: a slow radial inhale/exhale about the dot centroid - pointer repulsion: dots part around the cursor and spring back Physics runs in normalized space (resize just rescales the draw). Dots are blitted from a pre-rendered sprite via drawImage for speed, DPR is capped at 2, and the rAF loop pauses when the canvas is off-screen (IntersectionObserver) or the tab is hidden. Honors prefers-reduced-motion: renders the static stipple and never starts the loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TDdCdTJbUkH7ZoWDhj7AV
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
… face - breathing amplitude +30% (0.012 -> 0.0156) for a more visible inhale/exhale - pointer repulsion stronger and wider (strength 1.1 -> 2.6, radius 0.16 -> 0.20) - mask the curl shimmer over the head: a soft elliptical region around the face/hair where the swirl ramps to zero, so it no longer distorts the features. Breathing and pointer repulsion still apply there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TDdCdTJbUkH7ZoWDhj7AV
Make the pointer repulsion wider (radius 0.20 -> 0.50, ~2.5x) and replace the linear falloff — which peaked at the cursor and punched a clean hole — with a soft (1-q^2)^2 bump that has a rounded, zero-slope peak. Dots under the cursor are now nudged apart gently instead of being completely repelled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TDdCdTJbUkH7ZoWDhj7AV
Animation: run the relaxation at half speed. The spring's natural frequency is halved (SPRING 120->30, DAMP 22->11) and the forces are scaled to match (CURL_AMP, POINTER_STR /4; CURL_SPEED /2) so resting displacements are unchanged — only the settling is slower. Breathing is untouched. Pages: merge the Portfolio page into About. The portfolio Background, Education, and Projects now render below the About intro in content/pages/about.md; the redundant consulting-recap paragraph that duplicated the About intro is dropped. Remove the /portfolio route, content file, nav link, and sitemap entry, and add a permanent /portfolio -> /about redirect so existing links keep working. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TDdCdTJbUkH7ZoWDhj7AV
This was referenced Jun 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the static JPEG profile photo on the About page with an animated dot-art rendering using weighted Voronoi stippling. The portrait is now drawn as white dots on black, sized by brightness, with continuous animation driven by spring physics, curl-noise shimmer, breathing, and pointer repulsion.
Changes
components/ProfileDots.tsx(new): Client-side React component that renders the stipple portrait with physics-based animation. Loads pre-computed dot positions from JSON, runs a spring-damped simulation with curl-noise perturbation and breathing, responds to pointer hover, and respectsprefers-reduced-motion. The loop pauses when off-screen or the tab is hidden for performance.research/profile-dots/stipple.mjs(new): Node.js script implementing the StippleGen 2 algorithm (Adrian Secord, 2002). Seeds N points by importance sampling, then runs Lloyd relaxation toward density-weighted centroids using Jump-Flooding Algorithm (JFA) for Voronoi computation. Outputs quantized point coordinates and brightness values topublic/img/profile-dots/points.json.research/profile-dots/README.md(new): Documentation of the stippling algorithm, parameters, and regeneration instructions.app/about/page.tsx: Swapsnext/imagefor the static JPEG with the newProfileDotscomponent. Removes image optimization props (width, height, sizes, priority) since the canvas is client-rendered.styles/globals.scss: Addsaspect-ratio: 1 / 1to.post .profile-phototo maintain square layout for the canvas element.public/img/profile-dots/points.json(new): Pre-computed stipple data (8000 points, 4096-unit quantization).Implementation notes
https://claude.ai/code/session_017TDdCdTJbUkH7ZoWDhj7AV