- 01.Starting Point
- 02.Resolution Bump
- 03.TypeScript Fix: grid-force-3d Index Signature
- 04.Section Navigation
- 05.Four Backdrop Concepts
- 06.Flow Routes: The Iteration Journey
- 07.v1: Basic particles with trails
- 08.v2: Lifecycle fade envelope
- 09.v3: Ripple burst
- 10.v4: Pixel head
- 11.v5: Pixelated trail attempts
- 12.v6: Fading footprints (final)
- 13.Where We Landed
- 14.Takeaways
Lab Canvas Scaling + Flow Routes Particle Design
The design lab (`/lab`) was an infinite React Flow canvas with packet specimens (in a Figma-like frame) and grid specimens (individual nodes). All canvas-based specimens rendered at 200×200px internal
Starting Point
The design lab (/lab) was an infinite React Flow canvas with packet specimens (in a Figma-like frame) and grid specimens (individual nodes). All canvas-based specimens rendered at 200×200px internally, which pixelated when zooming in. The canvas had no navigation beyond scroll/zoom, and no zoom level indicator.
We'd also just created backdrop-network.tsx (the first "full background" concept) but hadn't wired it into the lab yet.
Resolution Bump
User reported pixelation when zooming into specimens. Root cause: canvas components rendered at 200px but React Flow scaled them via CSS when zooming past 1x.
Fixed by bumping internal SIZE from 200→300 in all canvas-based grid components:
grid-bundle.tsxgrid-tiles.tsxgrid-tiles-anim.tsxgrid-wind.tsxgrid-flow.tsx(usedconst size = 200inside useEffect, not a module constant)
Also bumped CELL_SIZE in the lab page from 200→300, and adjusted grid gaps from 260/280 to 360/380 to maintain proportional spacing.
SVG-based components (grid-hexbin, grid-force) and R3F (grid-force-3d) didn't need changes — they scale natively.
TypeScript Fix: grid-force-3d Index Signature
Pre-existing build failure:
Property 'position' comes from an index signature, so it must be accessed with ['position'].
Line 152: child.geometry.attributes.position → child.geometry.attributes['position']
Three.js BufferGeometry.attributes has an index signature, so TypeScript requires bracket notation.
Section Navigation
User asked for Figma-like navigation as the number of specimens grew. Added:
SectionNavcomponent — Panel at top-left with section buttons + zoom %useReactFlow().fitBounds()— Clicking a section smoothly animates (400ms duration) to that region- Three sections: Packets (y=0), Grids (y≈964), Backdrops (y≈2784)
- Computed bounds from node positions + sizes, stored in a
sections[]array
function SectionNav() {
const { fitBounds } = useReactFlow();
const { zoom } = useViewport();
return (
<Panel position="top-left">
{sections.map((section) => (
<button onClick={() => fitBounds(section.bounds, { padding: 0.15, duration: 400 })}>
{section.label}
</button>
))}
<span>{Math.round(zoom * 100)}%</span>
</Panel>
);
}Also added PreviewNode type — 600×380px container for backdrop specimens (vs 300×300 for grid specimens).
Four Backdrop Concepts
Created and wired into the Backdrops section:
-
Network Pulse (
backdrop-network.tsx) — Sparse Poisson-distributed nodes, curved edges via noise-offset control points, traveling packets. The "alive infrastructure" feel. -
Constellation Drift (
backdrop-constellation.tsx) — 22 nodes drift via simplex noise. Edges form/break dynamically by proximity threshold (120px). Star-chart feel. No explicit graph — just distance checks each frame. -
Bundle Highways (
backdrop-highways.tsx) — 6 corridor waypoints as infrastructure backbone. Routes connect endpoint nodes through nearest waypoints.BUNDLE_STRENGTH = 0.6pulls route midpoints toward corridors. Packets traverse polyline routes via distance-interpolation. -
Flow Routes (
backdrop-flowroutes.tsx) — The one that got iterated heavily. See below.
Flow Routes: The Iteration Journey
v1: Basic particles with trails
8 particles following simplex noise field, each with a trail of last 40 positions. Hard respawn when age > maxAge or off-screen. Result: jarring flicker as particles popped in/out.
v2: Lifecycle fade envelope
Added sine-curve fade-in (60 frames) and fade-out (80 frames):
function getLifecycleAlpha(age: number, maxAge: number): number {
if (age < FADE_IN_FRAMES) {
return Math.sin((age / FADE_IN_FRAMES) * Math.PI * 0.5);
}
const fadeStart = maxAge - FADE_OUT_FRAMES;
if (age > fadeStart) {
return Math.cos(((age - fadeStart) / FADE_OUT_FRAMES) * Math.PI * 0.5);
}
return 1;
}Initially tried quadratic (t*t) but it spent too long near full opacity then dropped suddenly. Sine curves are perceptually more even.
Also added: pre-simulation (particles start mid-lifecycle with built trails), off-screen detection forces early fade-out instead of instant death, trail shrinks during fade via visibleCount = trail.length * sqrt(lifeAlpha).
Longer life (400-700 frames vs 200-500), longer trails (50→90), slightly slower movement.
v3: Ripple burst
Added periodic expanding ring around particle head:
const rippleAge = p.age % RIPPLE_INTERVAL; // 90 frames between bursts
if (rippleAge < RIPPLE_DURATION) { // 40 frame expansion
const rippleT = rippleAge / RIPPLE_DURATION;
const radius = 3 + rippleT * RIPPLE_MAX_RADIUS; // 3→15px
const rippleAlpha = (1 - rippleT) * 0.3 * lifeAlpha;
ctx.arc(head.x, head.y, radius, ...);
}v4: Pixel head
Swapped ctx.arc circle for ctx.fillRect 3×3 square. Terminal/retro aesthetic.
v5: Pixelated trail attempts
Went through several iterations:
- Tapered pixels — size varied 1→2.5px from tail to head. User didn't want tapering.
- Uniform + spaced — 2×2 every 4th point. User wanted more spacing.
- Ghost trail — Same 3×3 as head, every 5th point, fading. "Afterimages." User wanted even more spread (bumped to every 9th).
But the fundamental problem: these all felt like a tail following the particle, not footprints left behind.
v6: Fading footprints (final)
The breakthrough was separating the visual from the position history. Instead of drawing from the trail array, particles now stamp independent Footprint objects at intervals:
interface Footprint {
x: number;
y: number;
age: number; // ages independently
}
// In animation loop:
p.stepsSinceStamp++;
if (p.stepsSinceStamp >= FOOTPRINT_INTERVAL) { // every 9 frames
p.footprints.push({ x: Math.round(head.x), y: Math.round(head.y), age: 0 });
p.stepsSinceStamp = 0;
}
// Footprints age + fade independently
for (const fp of p.footprints) {
fp.age++;
if (fp.age > FOOTPRINT_LIFE) { remove; continue; } // 120 frames to fully fade
const fadeT = 1 - fp.age / FOOTPRINT_LIFE;
const alpha = fadeT * fadeT * 0.3;
ctx.fillRect(fp.x - 1, fp.y - 1, 2, 2); // 2×2, smaller than 3×3 head
}The trail array was reduced to TRAIL_LENGTH = 3 (only needed for movement direction, never drawn).
Pre-simulation also stamps footprints with age = preAge - stepWhenStamped so particles start with partially-faded footprints already in place.
Key insight: footprints are 2×2, head is 3×3 — the size difference reinforces that the stamps are echoes, not the particle itself.
Where We Landed
The flow routes effect: sparse pixel particles glide along invisible noise paths, periodically emitting ripple pings, leaving behind fading 2×2 footprints that dissolve independently where they were placed. Particles fade in/out via sine curves so there's never a hard pop.
The lab canvas has three navigable sections (Packets, Grids, Backdrops) with smooth fitBounds transitions and a zoom indicator. All canvas specimens render at 300px (600px at 2x DPR) for clean zooming up to 2x.
Takeaways
- Trail vs footprint is a meaningful design distinction. Trails move with the entity (feel attached). Footprints stay in place (feel left behind). The implementation difference: trails draw from position history, footprints are independent objects with their own lifecycle.
- Sine curves for fade envelopes are perceptually more even than quadratic.
cos(t * PI/2)for fade-out,sin(t * PI/2)for fade-in. - Pre-simulation with age-aware initialization prevents the cold-start problem where all particles begin from zero simultaneously.
- React Flow's
fitBounds()withdurationgives smooth Figma-like section navigation for free.