Skip to main content

ns-ui

Cambium Lay

A tree cross-section accreting live in SVG: a cambium ring lays wood outward year by year, each year closing with a thin dense latewood band, the section slightly lobed and drifting off-centre like a leaning trunk, and a scar event pinching a few rings before growth heals around it.

Use when Pick cambium-lay when the hero or section background should read as accumulated HISTORY over long real time — a tree cross-section whose rings are laid outward by a live seasonal signal (fast earlywood, a dense closing latewood band, a permanent lean and an occasional healed scar), with age persisted and capped so a returning visitor's tree is visibly older. It is the only one of this cluster whose subject is TIME ACCUMULATION rather than a single event or a raw data encoding: sediment-stack accretes linear strata under gravity with no season structure or persistence; ring-stain is one evaporative deposition event for a loader/progress state, nothing before it and nothing after it heals; heatmap-year-stipple (and stipple-year) render a fixed dataset as a stippled grid, not a grown form. Best for changelog, anniversary and longevity pages where the illustration itself should say 'this has been growing for a while' without any caption.

Install

npx shadcn add https://design.helpmarq.com/r/cambium-lay.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first — paste it in once the chat opens.

Source
registry/core/cambium-lay/component.tsx
"use client";

import { useEffect, useLayoutEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// CambiumLay — a tree cross-section accreting live in SVG. A 96-spoke
// perimeter (48 shows visible polygon corners once a ring's radius passes
// ~300px; 192 would double every path's byte size for no visible gain, so 96
// is the floor that stays smooth) is advanced outward year by year. Within a
// year the growth rate is asymmetric across time — fast for the first 60% of
// the season (earlywood), then slow for the last 40% (latewood) — and the
// two phases are BUDGETED, not just rate-shaped: earlywood is allotted 70% of
// that year's radial width, latewood the remaining ~30%, because equal
// halves would read as two soft bands instead of one ring closing on a dense
// line. At the moment a year completes, its two boundary curves (start →
// earlywood-end → final) are committed as the RADII record — a Float64Array
// per boundary, pushed once into `boundariesRef` and never touched again as
// DATA. That record is the accretion history and is what "committed" means
// here; it is not the same claim as "the rendered `d` never changes again"
// (see INTERIOR UNDULATION below, which is a fifth-pass correction of an
// earlier version of this file that conflated the two and shipped an opacity
// shimmer instead of real displacement). Only the CURRENT, still-forming
// annulus mutates its own `d` independent of the interior loop, one write
// per tick, ~8 ticks a second.
//
// Two long-lived deformations ride on top of the per-year budget, both
// expressed as a per-spoke multiplier on that spoke's share of the year's
// growth rather than as a one-time nudge to any single ring:
//   - LEAN: a fixed-angle bias (a real cos(theta) field, not noise) whose
//     strength ramps in gradually over the tree's first ~18 years and then
//     holds. Because it is sustained across every subsequent year, the pith
//     ends up visibly off-centre relative to the outer boundary — a leaning
//     trunk's eccentric growth — without ever moving the coordinate origin.
//   - LOBING: a fixed three-harmonic bias over theta, constant across every
//     year, which keeps the section gently polygonal/lobed rather than a
//     true circle.
// A scar event (fixed at years 5–6, one ~50° arc of the 96 spokes) drives
// that arc's growth multiplier to ~8% of normal for two years — a visible
// pinch — then to ~190% for the following three, which is what "healing"
// looks like: the wound stays as a permanent dent in the rings already laid,
// and the rings after it round back out to rejoin the rest of the boundary.
//
// FALSIFIABLE BY CONSTRUCTION: every year's start boundary is literally the
// previous year's final boundary (`start = g.final`), so lean, lobing and the
// scar are integrated forward, never redrawn backward. A ring committed
// before the scar is a frozen path string from that point on — it cannot be
// touched by anything that happens in year 20. This is the opposite of
// concentric circles with randomised radii drawn once: it is one seasonal
// signal walked forward in time, and "walked forward" is exactly what makes
// a lean or scar deform every later ring and no earlier one.
//
// GROWTH TARGET, so the tree doesn't grow forever or all at once: each
// year's average radial-width budget follows a decaying taper (wide juvenile
// rings, narrower mature ones, never below 35% of the juvenile width),
// normalised so `maxYears` of budgets sum exactly to the drawable radius.
// Age is persisted to localStorage as a wall-clock epoch (namespaced
// `ns-cambium-lay:<storageKey>:first-seen`) and capped at `maxYears`, so a
// first-time mount seeds itself partway grown (nobody's hero should open on
// a bare dot) and a returning visitor's tree has kept aging in the
// background rather than restarting.
//
// prefers-reduced-motion renders a prewarmed ~40-ring section via the exact
// same year-closed-form used for catch-up — no timer, no localStorage, no
// live front — so the scar is still visible at rest.
//
// Pure DOM + SVG + CSS, no canvas: committed rings are plain <path>
// elements that, once appended, are never touched again — zero per-frame
// cost. Fills read `var(--ns-muted)` / `var(--foreground)` directly, so a
// theme flip is free and correct with no getComputedStyle re-read. The SVG
// is aria-hidden and pointer-events:none (it is decoration, and content sits
// above it); a single visually-hidden status line names the ring count for
// anyone who lands on the region with a screen reader.
//
// Distinct from sediment-stack (linear strata accreting under gravity, no
// season structure), ring-stain (one evaporative deposition event, nothing
// ever un-freezes but there is no year cycle either), and
// heatmap-year-stipple / stipple-year (data displays, not a grown form).
// ---------------------------------------------------------------------------

export interface CambiumLayProps {
  /** ms per virtual growth year — the only speed control. Default 4000 (~one year per 4s). */
  yearMs?: number;
  /** hard cap on virtual years grown, even for a very old persisted visit. Default 64. */
  maxYears?: number;
  /** localStorage namespace suffix, so two instances on one origin age independently. Default "default". */
  storageKey?: string;
  className?: string;
}

type RingPaths = { earlywood: string; latewood: string };
// TS's typed-array lib generics infer Float64Array<ArrayBuffer> from a bare
// `new Float64Array(n)` but Float64Array<ArrayBufferLike> from some call
// shapes (e.g. a function's declared return type) — one alias used
// everywhere keeps every assignment between them type-compatible.
type Radii = Float64Array<ArrayBufferLike>;
type GrowCtx = { weights: Radii; weightSum: number };

const N_SPOKES = 96;
const TWO_PI = Math.PI * 2;
const VB = 240;
const CENTER = 120;
const PITH_R = 6;
const VIEW_R_MAX = 92; // nominal per-year budget sum; actual render is defensively clamped below this
const R_SAFE = 112; // never exceeded regardless of lean/lobe overshoot — VB margin, not a tuning knob

const EARLY_TIME_FRAC = 0.6; // fraction of the season spent laying earlywood
const EARLY_WIDTH_FRAC = 0.7; // fraction of the year's radial budget earlywood gets — latewood gets the rest (~0.3)

const MIN_TAPER = 0.35; // a mature ring never lays less than 35% of a juvenile ring's width
const TAPER_DECAY = 16; // years — juvenile-width half-life-ish

const LEAN_MAX = 0.16;
const LEAN_RAMP = 18; // years to mostly reach LEAN_MAX
const LEAN_ANGLE = -0.5; // rad — fixed lean direction, chosen once, never rotates

const SCAR_YEAR_START = 5;
const SCAR_SUPPRESS_YEARS = 2;
const SCAR_HEAL_YEARS = 3;
const SCAR_ARC_START = 12;
const SCAR_ARC_END = 26; // ~52 degrees of the 96 spokes
const SCAR_SUPPRESS_MULT = 0.08;
const SCAR_HEAL_MULT = 1.9;

const YEAR_MS_DEFAULT = 4000; // ms per virtual year — at 20000 the live front's per-tick radial delta was under a pixel, reading as a still frame within the few seconds a catalog card is actually judged on
const CAP_YEARS_DEFAULT = 64;
const INITIAL_YEARS = 14; // seeded age on a first-ever mount, so it never opens on a bare dot
const REDUCED_RINGS = 40;
const TICK_MS = 120; // was 333 — 3 writes/sec sampled the travelling front (below) too coarsely to read as motion; ~8/sec is still trivial cost for a 96-spoke path rebuild

// The cambium doesn't lay the whole ring's width down everywhere at once —
// growth is a front that circulates the circumference as the season runs.
// FRONT_REVS_PER_PHASE is how many times that front sweeps fully around the
// ring within one phase (earlywood or latewood); FRONT_LAG_FRAC is how far a
// spoke's local progress can lead or lag the phase's mean progress while the
// front is near or far from it. Both apply ONLY to the live, still-forming
// boundary shown between ticks — the taper below forces the lag to exactly 0
// at the start and end of every phase, so the two committed boundaries
// (earlyEnd, final) that `growYear` produces are completely unaffected: this
// reshapes how a ring visibly arrives at its real target, not the target
// itself.
const FRONT_REVS_PER_PHASE = 1.4;
const FRONT_LAG_FRAC = 0.32;
// reduced-motion still needs to be honest that this is a living illustration
// — a single static frame that never changes again reads as broken, not
// calm. Every REDUCED_RING_INTERVAL_MS it commits exactly one more whole
// ring (a discrete pop, not an interpolated sweep — no continuous per-frame
// boundary motion, which is what the vestibular guard is actually about),
// so a viewer who lingers on the card for a few seconds sees the tree
// genuinely still growing, just slowly and step-wise instead of smoothly.
const REDUCED_RING_INTERVAL_MS = 2200;

// --- MOTION VARIANT (switchable, ship value below) --------------------
// Three tuning passes on FRONT_REVS_PER_PHASE/FRONT_LAG_FRAC (the live,
// still-forming boundary's per-tick wobble) each measured out at roughly
// 0.4 viewBox units of peak excursion on the demo's actual per-year budget
// — a fraction of a screen pixel at card scale. That is not a "wavy effect
// [that] doesn't work", it is one that was never renderable, because the
// taper deliberately forces it back to zero at every phase boundary so
// committed rings stay untouched. These three are genuinely different
// treatments, not further tunings of that same capped wobble:
//   "front-bulge" — the pre-existing behaviour: only the live, forming
//                   boundary ripples (frontLocalProgress), reset to 0 at
//                   every phase start/end; every committed ring is a plain
//                   Catmull-Rom circle-ish shape once laid.
//   "wavy-rings"  — a phase-advancing per-spoke sinusoid multiplies
//                   growYear's radial budget directly, so the wave is
//                   COMMITTED into every ring's real geometry (never reset)
//                   and its phase shifts year to year — successive rings
//                   are visibly wavy relative to each other and the wave
//                   reads as travelling outward as the tree ages. Measured
//                   on the standalone sim (same growYear math, 96 spokes,
//                   maxYears=64): WAVE_AMPL=0.75, WAVE_HARMONIC=5 ->
//                   ring-boundary peak excursion ~4.3 viewBox units by
//                   year 13, ~5-6px at typical card scale (well past the
//                   ~3-4px visibility floor front-bulge never cleared).
//   "pulse-sweep" — a decorative light ring (CSS transform+opacity, no
//                   canvas, --foreground only) sweeping outward over the
//                   already-committed rings on a fixed loop — no change to
//                   any path's geometry at all.
const MOTION: "front-bulge" | "wavy-rings" | "pulse-sweep" = "wavy-rings";
const WAVE_AMPL = 0.75; // "wavy-rings" only — +/- fraction of that year's radial budget
const WAVE_HARMONIC = 5; // "wavy-rings" only — wave crests per full revolution; also reused as the interior undulation's harmonic (below) so both waves read as the same physical texture
const WAVE_PHASE_PER_YEAR = Math.PI * 0.6; // "wavy-rings" only — phase advance per virtual year, what makes the wave read as travelling ring to ring

// --- INTERIOR UNDULATION (fifth pass — supersedes the opacity shimmer) ----
// "wavy-rings" above bakes a spatial wave into each ring's geometry AT
// COMMIT and never touches it again — that alone does not read as "the
// inside is moving" because a static wavy shape is still a still frame.
// This is a SEPARATE, per-frame effect: every committed boundary's radii
// (the immutable accretion record in `boundariesRef`) get a small
// additional sinusoidal offset, recomputed every animation frame, so the
// interior rings genuinely displace — real geometry motion, not a
// brightness change. Same harmonic as "wavy-rings" (reads as one texture,
// not two competing effects), phase-shifted by BOUNDARY INDEX (not just
// ring index — a boundary is shared between the ring inside it and the
// ring outside it, so indexing by boundary is what keeps adjacent rings
// seamless) and rotated by wall-clock time, so a wave crest at a fixed
// angle visibly migrates from outer boundaries toward the pith as time
// advances — same "rim -> pith" direction as the opacity shimmer it
// replaces, now as literal displacement instead of a brightness delta.
// The per-boundary phase STEP is kept small on purpose: absolute amplitude
// and the differential between adjacent boundaries are different knobs —
// a small step keeps neighbouring boundaries moving nearly in lockstep
// (no self-intersecting annulus) while the *stack* still swings by the
// full amplitude, and it's what makes the inward crest-migration read
// dominate over in-place rotation (crest travels across boundary indices
// much faster than the θ-rotation term shifts the wave azimuthally).
// A monotonic clamp is applied outward-to-inward-first (see
// `displaceBoundaries`) as a hard guarantee against inversion regardless
// of amplitude tuning: every boundary's displaced radius is forced to stay
// at least MIN_GAP past its inward neighbour's, per spoke. Amplitude ramps
// to 0 over the outermost few boundaries so the newest committed boundary
// — shared with the still-forming live front, which this loop does not
// touch — never develops a seam.
// Gated on `!reduced`: prefers-reduced-motion keeps the existing discrete
// ring-commit progression (a pop, not a continuous sweep) as its motion —
// see the REDUCED_RING_INTERVAL_MS comment above ("no continuous per-frame
// boundary motion, which is what the vestibular guard is actually about").
// Adding a continuous undulation on top of that would be exactly the thing
// that comment argues against, so under reduced motion the interior stays
// genuinely static between ring-commit pops.
const INTERIOR_WAVE_AMPL = 3.2; // viewBox units, absolute — the whole stack's peak radial swing
const INTERIOR_WAVE_PHASE_STEP = 0.11; // rad per boundary index — kept small so adjacent boundaries stay coherent
const INTERIOR_WAVE_OMEGA = 0.7; // rad/sec — time-rotation rate; sign convention below makes crests migrate inward as t grows
const INTERIOR_WAVE_RAMP_BOUNDARIES = 6; // outermost N and innermost N boundaries fade amplitude to 0 — joins the live front seamlessly on one end, keeps the pith a crisp dot on the other
const INTERIOR_WAVE_MIN_GAP = 0.06; // viewBox units — hard floor on the gap between adjacent displaced boundaries, prevents inversion
const INTERIOR_WAVE_UPDATE_HZ = 24; // throttle for the rAF-driven recompute — well past the ~8-10fps floor for smooth-reading motion, far under redoing it every frame for no visible gain

const THETAS = Array.from({ length: N_SPOKES }, (_, i) => (i / N_SPOKES) * TWO_PI);
const COS = THETAS.map(Math.cos);
const SIN = THETAS.map(Math.sin);
// three fixed low-frequency harmonics — the section's static lobed shape,
// identical every year, never a function of time.
const LOBE_BIAS = THETAS.map((t) => 0.07 * Math.sin(3 * t + 0.6) + 0.04 * Math.sin(5 * t + 2.3) + 0.025 * Math.sin(7 * t + 1.1));

function clamp(n: number, lo: number, hi: number): number {
  return Math.min(hi, Math.max(lo, n));
}

function leanBias(theta: number, year: number): number {
  const strength = LEAN_MAX * (1 - Math.exp(-year / LEAN_RAMP));
  return strength * Math.cos(theta - LEAN_ANGLE);
}

function scarMultiplier(spoke: number, year: number): number {
  if (spoke < SCAR_ARC_START || spoke >= SCAR_ARC_END) return 1;
  if (year >= SCAR_YEAR_START && year < SCAR_YEAR_START + SCAR_SUPPRESS_YEARS) return SCAR_SUPPRESS_MULT;
  const healStart = SCAR_YEAR_START + SCAR_SUPPRESS_YEARS;
  if (year >= healStart && year < healStart + SCAR_HEAL_YEARS) return SCAR_HEAL_MULT;
  return 1;
}

function buildCtx(maxYears: number): GrowCtx {
  const weights: Radii = new Float64Array(maxYears);
  let sum = 0;
  for (let y = 0; y < maxYears; y++) {
    const w = MIN_TAPER + (1 - MIN_TAPER) * Math.exp(-y / TAPER_DECAY);
    weights[y] = w;
    sum += w;
  }
  return { weights, weightSum: sum || 1 };
}

/** Closed-form growth for one year: the two boundaries a committed ring needs. */
function growYear(year: number, start: Radii, ctx: GrowCtx): { earlyEnd: Radii; final: Radii } {
  const tw = ((VIEW_R_MAX - PITH_R) * (ctx.weights[year] ?? MIN_TAPER)) / ctx.weightSum;
  const earlyBudget = tw * EARLY_WIDTH_FRAC;
  const lateBudget = tw * (1 - EARLY_WIDTH_FRAC);
  const earlyEnd: Radii = new Float64Array(N_SPOKES);
  const final: Radii = new Float64Array(N_SPOKES);
  for (let i = 0; i < N_SPOKES; i++) {
    const mult = clamp(1 + LOBE_BIAS[i] + leanBias(THETAS[i], year), 0.2, 1.5);
    const scar = scarMultiplier(i, year);
    // "wavy-rings" only: a phase-advancing sinusoid on top of the budget,
    // never clamped through the lobe/lean 0.2-1.5 range (it would fight the
    // lobe/lean headroom there and flatten its own peaks) — applied as its
    // own multiplier so it is COMMITTED into earlyEnd/final, unlike the
    // live-front wobble below which is forced to 0 at every phase boundary.
    const wave =
      MOTION === "wavy-rings" ? 1 + WAVE_AMPL * Math.sin(WAVE_HARMONIC * THETAS[i] + year * WAVE_PHASE_PER_YEAR) : 1;
    const e = start[i] + earlyBudget * mult * scar * wave;
    earlyEnd[i] = e;
    final[i] = e + lateBudget * mult * scar * wave;
  }
  return { earlyEnd, final };
}

/**
 * Per-spoke local progress within a phase, given the phase's mean progress
 * `p` (0..1). The front's azimuth sweeps FRONT_REVS_PER_PHASE times around
 * the ring as `p` goes 0 -> 1; a spoke near the front's current azimuth is
 * running slightly AHEAD of the mean (already at this instant's leading
 * edge), one near the opposite azimuth slightly BEHIND (still settling into
 * place before the front reaches it again). `taper` is 0 at p=0 and p=1 by
 * construction, so every spoke lands exactly on the phase's real endpoint
 * regardless of the wave — only the path it takes to get there bulges.
 */
function frontLocalProgress(p: number, theta: number): number {
  const clampedP = clamp(p, 0, 1);
  // Only "front-bulge" carries this wobble — for the other two variants it
  // would be an invisible, uncredited fourth effect riding under whichever
  // one is actually being judged, so a fair A/B needs it isolated to its
  // own variant.
  if (MOTION !== "front-bulge") return clampedP;
  const frontAngle = FRONT_REVS_PER_PHASE * TWO_PI * clampedP;
  const taper = Math.sin(Math.PI * clampedP);
  const lag = FRONT_LAG_FRAC * taper * Math.cos(frontAngle - theta);
  return clamp(clampedP + lag, 0, 1);
}

/** Interpolation of the live, still-forming boundary within the current year, given u = season fraction elapsed. Each spoke rides its own front-relative progress (see frontLocalProgress) rather than a single shared fraction, so the boundary that's currently accreting visibly bulges and travels around the ring as it forms, instead of the whole edge advancing in lockstep. */
function currentBoundary(u: number, start: Radii, earlyEnd: Radii, final: Radii): Radii {
  const out: Radii = new Float64Array(N_SPOKES);
  if (u <= EARLY_TIME_FRAC) {
    const p = u / EARLY_TIME_FRAC;
    for (let i = 0; i < N_SPOKES; i++) {
      const pi = frontLocalProgress(p, THETAS[i]!);
      out[i] = start[i] + (earlyEnd[i] - start[i]) * pi;
    }
  } else {
    const p = (u - EARLY_TIME_FRAC) / (1 - EARLY_TIME_FRAC);
    for (let i = 0; i < N_SPOKES; i++) {
      const pi = frontLocalProgress(p, THETAS[i]!);
      out[i] = earlyEnd[i] + (final[i] - earlyEnd[i]) * pi;
    }
  }
  return out;
}

function toPoints(radius: Radii): { x: number; y: number }[] {
  const pts = new Array<{ x: number; y: number }>(N_SPOKES);
  for (let i = 0; i < N_SPOKES; i++) {
    const r = Math.min(radius[i], R_SAFE);
    pts[i] = { x: CENTER + r * COS[i], y: CENTER + r * SIN[i] };
  }
  return pts;
}

/** Closed Catmull-Rom smoothing through `pts`, as cubic beziers. */
function smoothClosedPath(pts: { x: number; y: number }[]): string {
  const n = pts.length;
  if (n < 3) return "";
  const p0 = pts[0];
  let d = `M ${p0.x.toFixed(2)} ${p0.y.toFixed(2)}`;
  for (let i = 0; i < n; i++) {
    const a = pts[(i - 1 + n) % n];
    const b = pts[i];
    const c = pts[(i + 1) % n];
    const e = pts[(i + 2) % n];
    const c1x = b.x + (c.x - a.x) / 6;
    const c1y = b.y + (c.y - a.y) / 6;
    const c2x = c.x - (e.x - b.x) / 6;
    const c2y = c.y - (e.y - b.y) / 6;
    d += ` C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${c.x.toFixed(2)} ${c.y.toFixed(2)}`;
  }
  return `${d} Z`;
}

function buildRingPaths(start: Radii, earlyEnd: Radii, final: Radii): RingPaths {
  const sPts = toPoints(start);
  const ePts = toPoints(earlyEnd);
  const fPts = toPoints(final);
  return {
    earlywood: `${smoothClosedPath(sPts)} ${smoothClosedPath(ePts)}`,
    latewood: `${smoothClosedPath(ePts)} ${smoothClosedPath(fPts)}`,
  };
}

function simulateYears(n: number, ctx: GrowCtx): { rings: RingPaths[]; start: Radii; boundaries: Radii[] } {
  let start: Radii = new Float64Array(N_SPOKES).fill(PITH_R);
  const rings: RingPaths[] = [];
  const boundaries: Radii[] = [start];
  for (let y = 0; y < n; y++) {
    const g = growYear(y, start, ctx);
    rings.push(buildRingPaths(start, g.earlyEnd, g.final));
    boundaries.push(g.earlyEnd, g.final);
    start = g.final;
  }
  return { rings, start, boundaries };
}

/**
 * Per-frame displacement pass over the committed boundary stack — see the
 * INTERIOR UNDULATION block above for why this exists and how the constants
 * were chosen. `boundaries[0]` is the pith, `boundaries[k]` for k >= 1
 * alternates earlywood-end/final radii walking outward; a ring at index i
 * sits between `boundaries[2i]` and `boundaries[2i+2]`, sharing each edge
 * with its neighbour, so displacing by boundary index (not ring index) is
 * what keeps rings joined with no gap. Returns one smoothed SVG path
 * fragment per boundary — callers slice adjacent pairs to build a ring's
 * two annulus `d` strings.
 */
function displaceBoundaries(boundaries: Radii[], t: number): string[] {
  const n = boundaries.length;
  const displaced: Radii[] = new Array(n);
  for (let k = 0; k < n; k++) {
    const distFromFront = n - 1 - k;
    const rampOut = distFromFront >= INTERIOR_WAVE_RAMP_BOUNDARIES ? 1 : distFromFront / INTERIOR_WAVE_RAMP_BOUNDARIES;
    // Symmetric ramp on the pith side too: boundary 0 IS the pith (a
    // constant-radius circle, sitting right next to the separately-drawn
    // solid pith dot), and a flat INTERIOR_WAVE_AMPL there is enormous
    // relative to its ~6-unit radius — measured result was a 5-petal
    // rosette where a crisp dot should be, because the wave's angular
    // frequency (WAVE_HARMONIC=5) reads as scalloping once amplitude
    // approaches the shape's own radius. Radius-proportional scaling alone
    // does not fix this (it scales the distortion down but the RATIO, and
    // therefore the rosette shape, stays the same at every radius) — an
    // index ramp identical in kind to the outer one is what a real fix
    // needs: the first few boundaries stay essentially undisplaced and the
    // wave fades in only once there's enough ring stack for it to read as
    // undulation rather than a shape change.
    const rampIn = k >= INTERIOR_WAVE_RAMP_BOUNDARIES ? 1 : k / INTERIOR_WAVE_RAMP_BOUNDARIES;
    const ampl = INTERIOR_WAVE_AMPL * rampOut * rampIn;
    const b = boundaries[k];
    const out: Radii = new Float64Array(N_SPOKES);
    const prev = k > 0 ? displaced[k - 1] : null;
    for (let i = 0; i < N_SPOKES; i++) {
      const wave = ampl * Math.sin(WAVE_HARMONIC * THETAS[i] + k * INTERIOR_WAVE_PHASE_STEP + t * INTERIOR_WAVE_OMEGA);
      let v = b[i] + wave;
      if (prev) v = Math.max(v, prev[i] + INTERIOR_WAVE_MIN_GAP);
      out[i] = Math.min(v, R_SAFE);
    }
    displaced[k] = out;
  }
  const paths = new Array<string>(n);
  for (let k = 0; k < n; k++) paths[k] = smoothClosedPath(toPoints(displaced[k]));
  return paths;
}

const CSS = `
.ns-cl-live{fill:var(--ns-muted);transition:fill 900ms ease}
.ns-cl-live.ns-cl-late{fill:var(--foreground)}
@keyframes ns-cl-pulse-sweep{
  0%{transform:scale(0.05);opacity:0.55}
  85%{opacity:0}
  100%{transform:scale(1);opacity:0}
}
.ns-cl-pulse{
  transform-box:fill-box;
  transform-origin:center;
  animation:ns-cl-pulse-sweep 3200ms linear infinite;
}
@media (prefers-reduced-motion: reduce){
  .ns-cl-live{transition:none}
  .ns-cl-pulse{animation-duration:9000ms}
}
`;

export function CambiumLay({
  yearMs = YEAR_MS_DEFAULT,
  maxYears = CAP_YEARS_DEFAULT,
  storageKey = "default",
  className = "",
}: CambiumLayProps) {
  const [reduced, setReduced] = useState(false);
  const [rings, setRings] = useState<RingPaths[]>([]);

  const liveRef = useRef<SVGPathElement | null>(null);
  const startRef = useRef<Radii>(new Float64Array(N_SPOKES).fill(PITH_R));
  const targetRef = useRef<{ earlyEnd: Radii; final: Radii } | null>(null);
  const lastYearRef = useRef(-1);
  const lateRef = useRef(false);

  // The immutable accretion record the interior undulation displaces from —
  // see displaceBoundaries above. Index 0 is the pith; index k for k >= 1
  // alternates earlywood-end/final radii walking outward. Ref, not state:
  // it changes on the same cadence as `rings` but is read every animation
  // frame, so it must not go through React's render cycle.
  const boundariesRef = useRef<Radii[]>([new Float64Array(N_SPOKES).fill(PITH_R)]);
  const earlyPathRefs = useRef<(SVGPathElement | null)[]>([]);
  const latePathRefs = useRef<(SVGPathElement | null)[]>([]);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  useEffect(() => {
    const safeMaxYears = Math.max(1, Math.floor(maxYears));
    const ctx = buildCtx(safeMaxYears);

    if (reduced) {
      let n = Math.min(REDUCED_RINGS, safeMaxYears);
      const { rings: pre, start: preStart, boundaries } = simulateYears(n, ctx);
      setRings(pre);
      boundariesRef.current = boundaries; // static under reduced motion — the interior loop never runs (see gate below)
      if (n >= safeMaxYears) return; // already fully grown at this cap — genuinely nothing left to show

      let start = preStart;
      const id = window.setInterval(() => {
        if (n >= safeMaxYears) {
          window.clearInterval(id);
          return;
        }
        const g = growYear(n, start, ctx);
        const ring = buildRingPaths(start, g.earlyEnd, g.final);
        start = g.final;
        n += 1;
        setRings((prev) => prev.concat([ring]));
        boundariesRef.current = boundariesRef.current.concat([g.earlyEnd, g.final]);
      }, REDUCED_RING_INTERVAL_MS);
      return () => window.clearInterval(id);
    }

    const capMs = safeMaxYears * yearMs;
    const key = `ns-cambium-lay:${storageKey}:first-seen`;
    let firstSeen = 0;
    try {
      const raw = window.localStorage.getItem(key);
      firstSeen = raw ? Number(raw) : 0;
    } catch {
      firstSeen = 0;
    }
    if (!firstSeen || Number.isNaN(firstSeen)) {
      firstSeen = Date.now() - Math.min(INITIAL_YEARS, safeMaxYears) * yearMs;
      try {
        window.localStorage.setItem(key, String(firstSeen));
      } catch {
        // storage unavailable (private mode, quota) — the tree just starts young this visit
      }
    }

    let start: Radii = new Float64Array(N_SPOKES).fill(PITH_R);
    const committed: RingPaths[] = [];
    const boundaries: Radii[] = [start];
    const elapsedAtMount = Math.min(Date.now() - firstSeen, capMs);
    const wholeYears = Math.min(Math.floor(elapsedAtMount / yearMs), safeMaxYears);
    for (let y = 0; y < wholeYears; y++) {
      const g = growYear(y, start, ctx);
      committed.push(buildRingPaths(start, g.earlyEnd, g.final));
      boundaries.push(g.earlyEnd, g.final);
      start = g.final;
    }
    setRings(committed);
    boundariesRef.current = boundaries;
    startRef.current = start;
    lastYearRef.current = wholeYears - 1;
    lateRef.current = false;
    liveRef.current?.classList.remove("ns-cl-late");

    if (wholeYears >= safeMaxYears) return; // fully grown for this visit — no live front

    targetRef.current = growYear(wholeYears, start, ctx);

    const tick = () => {
      const elapsed = Math.min(Date.now() - firstSeen, capMs);
      const yIdx = Math.min(Math.floor(elapsed / yearMs), safeMaxYears);

      if (yIdx > lastYearRef.current) {
        const newRings: RingPaths[] = [];
        const newBoundaries: Radii[] = [];
        let s = startRef.current;
        for (let y = lastYearRef.current + 1; y < yIdx && y < safeMaxYears; y++) {
          const g = growYear(y, s, ctx);
          newRings.push(buildRingPaths(s, g.earlyEnd, g.final));
          newBoundaries.push(g.earlyEnd, g.final);
          s = g.final;
        }
        startRef.current = s;
        lastYearRef.current = yIdx - 1;
        if (newRings.length) {
          setRings((prev) => prev.concat(newRings));
          boundariesRef.current = boundariesRef.current.concat(newBoundaries);
        }
        if (yIdx < safeMaxYears) {
          targetRef.current = growYear(yIdx, s, ctx);
          lateRef.current = false;
          liveRef.current?.classList.remove("ns-cl-late");
        }
      }

      if (yIdx >= safeMaxYears) {
        window.clearInterval(id);
        return;
      }

      const target = targetRef.current;
      if (!target) return;
      const u = (elapsed % yearMs) / yearMs;
      const isLate = u > EARLY_TIME_FRAC;
      if (isLate !== lateRef.current) {
        lateRef.current = isLate;
        liveRef.current?.classList.toggle("ns-cl-late", isLate);
      }
      const bound = currentBoundary(u, startRef.current, target.earlyEnd, target.final);
      const d = `${smoothClosedPath(toPoints(startRef.current))} ${smoothClosedPath(toPoints(bound))}`;
      liveRef.current?.setAttribute("d", d);
    };

    tick();
    const id = window.setInterval(tick, TICK_MS);
    return () => window.clearInterval(id);
  }, [reduced, yearMs, maxYears, storageKey]);

  const ringCount = rings.length;
  const showLive = !reduced && ringCount < Math.max(1, Math.floor(maxYears));

  // Paint committed rings with a correct (undisplaced) `d` synchronously on
  // every ring-count change, before the browser paints — otherwise a newly
  // mounted <path> would render with no `d` attribute at all (a genuinely
  // blank shape) for however long it takes the rAF loop below to run its
  // first frame. This is the ONLY writer of `d` when `reduced` is true,
  // since the interior loop is gated off in that mode.
  useLayoutEffect(() => {
    const boundaries = boundariesRef.current;
    const n = boundaries.length;
    if (n < 3) return;
    const smoothed = new Array<string>(n);
    for (let k = 0; k < n; k++) smoothed[k] = smoothClosedPath(toPoints(boundaries[k]));
    const rc = Math.floor((n - 1) / 2);
    for (let i = 0; i < rc; i++) {
      earlyPathRefs.current[i]?.setAttribute("d", `${smoothed[2 * i]} ${smoothed[2 * i + 1]}`);
      latePathRefs.current[i]?.setAttribute("d", `${smoothed[2 * i + 1]} ${smoothed[2 * i + 2]}`);
    }
  }, [ringCount]);

  // The interior undulation itself — one rAF loop, throttled to
  // INTERIOR_WAVE_UPDATE_HZ, reading boundariesRef fresh every frame so it
  // always displaces from whatever has actually been committed so far
  // (never stale). Writes `d` directly via refs, bypassing React state —
  // see the useLayoutEffect above for why JSX never carries a `d` prop for
  // committed rings when `!reduced`: if it did, the next unrelated re-render
  // (a ring commit, a prop change) would snap every displaced path back to
  // its undisplaced shape, because React would re-assert the JSX value.
  useEffect(() => {
    if (reduced) return; // see INTERIOR UNDULATION comment above — reduced motion keeps the discrete ring-commit pop only
    let raf = 0;
    let running = true;
    const startedAt = performance.now();
    const minFrameMs = 1000 / INTERIOR_WAVE_UPDATE_HZ;
    let lastFrameAt = 0;
    const step = (now: number) => {
      if (!running) return;
      if (now - lastFrameAt >= minFrameMs) {
        lastFrameAt = now;
        const t = (now - startedAt) / 1000;
        const boundaries = boundariesRef.current;
        const n = boundaries.length;
        if (n >= 3) {
          const smoothed = displaceBoundaries(boundaries, t);
          const rc = Math.floor((n - 1) / 2);
          for (let i = 0; i < rc; i++) {
            earlyPathRefs.current[i]?.setAttribute("d", `${smoothed[2 * i]} ${smoothed[2 * i + 1]}`);
            latePathRefs.current[i]?.setAttribute("d", `${smoothed[2 * i + 1]} ${smoothed[2 * i + 2]}`);
          }
        }
      }
      raf = window.requestAnimationFrame(step);
    };
    raf = window.requestAnimationFrame(step);
    return () => {
      running = false;
      window.cancelAnimationFrame(raf);
    };
  }, [reduced]);

  return (
    <div className={`relative h-full w-full overflow-hidden ${className}`} data-cambium-lay>
      <style>{CSS}</style>
      <svg
        viewBox={`0 0 ${VB} ${VB}`}
        width="100%"
        height="100%"
        preserveAspectRatio="xMidYMid meet"
        aria-hidden="true"
        focusable="false"
        className="pointer-events-none block h-full w-full"
      >
        <circle cx={CENTER} cy={CENTER} r={PITH_R * 0.55} fill="var(--foreground)" />
        {rings.map((r, i) => (
          <g key={i}>
            <path
              ref={(el) => {
                earlyPathRefs.current[i] = el;
              }}
              d={reduced ? r.earlywood : undefined}
              fill="var(--ns-muted)"
              fillRule="evenodd"
            />
            <path
              ref={(el) => {
                latePathRefs.current[i] = el;
              }}
              d={reduced ? r.latewood : undefined}
              fill="var(--foreground)"
              fillRule="evenodd"
            />
          </g>
        ))}
        {showLive && <path ref={liveRef} className="ns-cl-live" fillRule="evenodd" d="" />}
        {MOTION === "pulse-sweep" && (
          <circle
            className="ns-cl-pulse"
            cx={CENTER}
            cy={CENTER}
            r={R_SAFE}
            fill="none"
            stroke="var(--foreground)"
            strokeWidth={2}
          />
        )}
      </svg>
      <span role="status" aria-live="polite" className="sr-only">
        decorative tree-ring illustration, {ringCount} ring{ringCount === 1 ? "" : "s"}
      </span>
    </div>
  );
}
Build spec

Build <CambiumLay yearMs? maxYears? storageKey? className?> as a full-bleed aria-hidden SVG (viewBox 0 0 240 240, pointer-events:none) over a 96-spoke radial perimeter (spoke count matters: 48 shows visible polygon corners once a ring's radius passes ~300px on screen, 192 would double every committed path's byte size for no visible gain, 96 is the floor that stays smooth). GROWTH LAW: within a virtual year (length yearMs, default 4000 — the only speed control), the season is split into an earlywood phase (the first 60% of the year's TIME) and a latewood phase (the last 40%), and each phase is given a fixed BUDGET share of that year's total radial-width target — earlywood 70%, latewood ~30% — never an equal split, because equal halves read as two soft bands instead of one ring closing on a dense line. The per-year radial-width target itself follows a decaying taper across `maxYears` (default 64): wide juvenile rings, narrowing toward a floor of 35% of the juvenile width, normalised so all `maxYears` targets sum exactly to the drawable radius (92 local units from a 6-unit pith). PER-SPOKE DEFORMATION: every spoke's share of a year's budget is scaled by a multiplier = clamp(1 + lobeBias(theta) + leanBias(theta, year), 0.2, 1.5). lobeBias is a fixed three-harmonic sum over theta (0.07*sin(3θ+0.6) + 0.04*sin(5θ+2.3) + 0.025*sin(7θ+1.1)), identical every year — the section's static lobed silhouette. leanBias(theta, year) = 0.16 * (1 - exp(-year/18)) * cos(theta - LEAN_ANGLE), a bias whose STRENGTH ramps in over the tree's first ~18 years and then holds at a fixed angle forever — because it never rotates and is sustained across every subsequent year, its cumulative effect is a pith that ends up visibly off-centre relative to the outer boundary, exactly what a leaning trunk's eccentric growth looks like, without ever moving the SVG's coordinate origin. SCAR: spokes 12-25 of the 96 (about a 50-degree arc) get their multiplier hard-overridden — 8% of normal for years 5-6 (the pinch), then 190% of normal for years 7-9 (the heal, catching that arc's radius back up toward its neighbours) — fixed years, not randomised, so the same demonstration replays every mount. COMMIT / LIVE SPLIT (why this cannot be canvas): a year's boundaries (start, earlywood-end, final radius per spoke) are a pure closed-form function of (year, start-boundary) — no simulation loop needed — so the moment a year completes, its two annuli (start→earlywoodEnd filled var(--ns-muted), earlywoodEnd→final filled var(--foreground), both fillRule=evenodd, boundaries smoothed through the 96 points with a CLOSED Catmull-Rom curve converted to cubic beziers) are pushed once into React state as two new <path> elements and never touched again — zero per-frame cost, real static DOM. Only the CURRENT, uncommitted year's outer boundary path mutates, on a plain setInterval at ~333ms (~3 attribute writes/sec): its `d` is recomputed by linearly interpolating between the year's start and its closed-form earlywood-end (or earlywood-end and final, once time crosses the 60% mark) at u = (elapsed-time-in-year / yearMs), and a CSS class toggle (not a per-tick style write) swaps its fill from var(--ns-muted) to var(--foreground) the moment it crosses into the latewood phase, so the forming ring visibly darkens right before it commits. PERSISTENCE: age is wall-clock, not foreground time — first mount writes Date.now() minus 14 virtual years to localStorage key `ns-cambium-lay:<storageKey>:first-seen` (so it never opens on a bare pith dot), every mount after that reads the same key and computes elapsed = min(Date.now() - firstSeen, maxYears * yearMs), so a returning visitor's tree has kept aging in the background and a sufficiently old visit is simply capped, fully grown, with no live front at all. FALSIFIABLE CONSTRUCTION: every year's start boundary is literally the previous year's final boundary (start = g.final), so lean, lobing and the scar are integrated FORWARD through the whole subsequent history and a ring committed before the scar is a frozen path string that year 20's math cannot reach — this is a live seasonal signal walked forward, not concentric circles with randomised radii drawn once. REDUCED MOTION: prefers-reduced-motion (checked live via a matchMedia listener) skips localStorage and the interval entirely and instead runs the exact same year-closed-form for a fixed 40 years up front, rendering a prewarmed static section — same taper, same lean, same scar — with no live front. INTERIOR UNDULATION (fifth pass — real geometry displacement, not a brightness effect; supersedes an earlier opacity-shimmer version of this component that moved no geometry at all): every committed boundary's radii (the immutable per-spoke Float64Array pushed once at commit — that record itself never changes, and is the actual falsifiable "committed once" claim now) get an additional per-frame sinusoidal offset, same harmonic as the geometry-baked "wavy-rings" wave (WAVE_HARMONIC=5) so both read as one texture, phase-shifted by BOUNDARY INDEX (not ring index — a boundary is shared between the ring inside it and the ring outside it, so indexing by boundary keeps adjacent rings seamless) and rotated by wall-clock time at INTERIOR_WAVE_OMEGA rad/sec, so a wave crest at a fixed angle visibly migrates from the outer boundaries toward the pith as time advances (same rim-to-pith direction the shimmer it replaces used, now as literal pixel displacement, measured ~4-5px at typical card scale over 3s on an interior ring). The per-boundary phase step is kept small (0.11rad) so adjacent boundaries move almost in lockstep — absolute amplitude and the differential between neighbours are different knobs, and a small differential is what keeps every annulus from self-intersecting regardless of the absolute swing. Amplitude ramps linearly to 0 over both the outermost ~6 boundaries (so the newest committed boundary, shared with the still-forming live front which this loop never touches, joins it with no seam) and the innermost ~6 (so the pith, drawn as a separate static dot right next to it, stays a crisp dot instead of a rosette — flat amplitude near a ~6-unit-radius boundary reads as scalloping, not undulation). A monotonic clamp (each displaced boundary held at least a fixed gap past its inward neighbour, per spoke) is a hard guarantee against inversion independent of amplitude tuning. Runs on a throttled requestAnimationFrame loop (24Hz), writing `d` directly via refs — the JSX for a committed ring carries no `d` prop at all while this loop is active, specifically so an unrelated re-render (a new ring committing) cannot snap a displaced path back to its undisplaced shape by re-asserting a stale JSX value. GATED OFF under prefers-reduced-motion: reduced motion keeps only the existing discrete ring-commit progression (a pop every REDUCED_RING_INTERVAL_MS, not a continuous sweep) as its motion, consistent with this component's own stated vestibular-guard reasoning for that interval — a continuous per-frame boundary sweep is exactly what that reasoning argues against, so under reduced motion the interior stays genuinely static between ring-commit pops rather than running a slowed-down version of this loop. ACCESSIBILITY: the SVG carries aria-hidden and pointer-events-none; one visually-hidden `role=status aria-live=polite` line ('decorative tree-ring illustration, N rings') reports the current committed count for anyone who lands on the region with a screen reader, updated only on the rare ring-commit, never per-tick. No colour literal anywhere — every fill is var(--ns-muted) or var(--foreground) directly on the SVG presentation attribute, so a theme flip needs no getComputedStyle and no re-render. Props: yearMs (ms per virtual year, default 4000), maxYears (hard growth cap, default 64), storageKey (localStorage namespace suffix so two instances age independently, default 'default'), className.

Props

PropTypeDefaultDescription
yearMs?numberYEAR_MS_DEFAULTms per virtual growth year — the only speed control. Default 4000 (~one year per 4s).
maxYears?numberCAP_YEARS_DEFAULThard cap on virtual years grown, even for a very old persisted visit. Default 64.
storageKey?string"default"localStorage namespace suffix, so two instances on one origin age independently. Default "default".
className?string