COORD: 44.21.90
OFFSET: +12.5°
SYS.READY
BUFFER: 99%
FOCAL_PT
BACK TO DEVLOG
BRUBKR

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

2025-01-23 // RAW LEARNING CAPTURE
PROJECTBRUBKR

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.tsx
  • grid-tiles.tsx
  • grid-tiles-anim.tsx
  • grid-wind.tsx
  • grid-flow.tsx (used const size = 200 inside 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.positionchild.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:

  1. SectionNav component — Panel at top-left with section buttons + zoom %
  2. useReactFlow().fitBounds() — Clicking a section smoothly animates (400ms duration) to that region
  3. Three sections: Packets (y=0), Grids (y≈964), Backdrops (y≈2784)
  4. 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:

  1. Network Pulse (backdrop-network.tsx) — Sparse Poisson-distributed nodes, curved edges via noise-offset control points, traveling packets. The "alive infrastructure" feel.

  2. 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.

  3. Bundle Highways (backdrop-highways.tsx) — 6 corridor waypoints as infrastructure backbone. Routes connect endpoint nodes through nearest waypoints. BUNDLE_STRENGTH = 0.6 pulls route midpoints toward corridors. Packets traverse polyline routes via distance-interpolation.

  4. 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:

  1. Tapered pixels — size varied 1→2.5px from tail to head. User didn't want tapering.
  2. Uniform + spaced — 2×2 every 4th point. User wanted more spacing.
  3. 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() with duration gives smooth Figma-like section navigation for free.
LOG.ENTRY_END
ref:brubkr
RAW