← All particles

Capacity Fill

A vessel filling with grains that roll into a heap and level off as it nears full.

statustechnicalcalm320 particles · moderate · canvas-2d · automatic · looping
Variant

The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.

441 lines · react only
import { useEffect, useRef } from "react";

/**
 * Vibary · Capacity Fill
 *
 * A vessel filling with grains that fall, roll down the heap and stop.
 * The pile stands as a mound while the vessel is nearly empty and is
 * almost level by the time it is nearly full.
 *
 * The technique: that levelling is not animated, it is a consequence.
 * A grain rolls on while any neighbouring column stands more than the
 * repose slope below it, so the surface can never be steeper than about
 * thirty degrees. That rule caps the *difference* in height across the
 * vessel — peak to wall, roughly half the width times the repose slope —
 * and the cap is a constant. Measured here it is 26px whatever the fill,
 * against a depth that grows from 14px to 167px, so unevenness runs from
 * nearly twice the depth at a tenth full to a sixth of it at the brim.
 * Nothing smooths the top as the level rises; a fixed maximum roughness
 * against a growing depth does all of it.
 *
 * The same arithmetic decides the shape of the vessel. The cap is a
 * fraction of the *width*, so a tall narrow gauge levels convincingly
 * and a wide shallow tray never can — in the same simulation a 300x70
 * tray is still 1.1 times as rough as it is deep when full. That is why
 * this one is a column.
 *
 * A grain raises its column by less than a third of the column's width,
 * which is what makes the repose slope a real angle rather than a
 * staircase, and it is why the grains nestle instead of stacking like
 * coins. The settled pile is painted into a buffer once per arrival and
 * blitted, so a full vessel costs the same per frame as an empty one.
 *
 * Self-contained: one canvas plus an offscreen buffer, no dependencies.
 * Works with zero props; tune via `value`, `capacity`, `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CapacityFillProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** How full, 0 to 1. The pile height is the count, not a tween. */
  value?: number;
  /** Grains the vessel holds when full. This is the real particle count. */
  capacity?: number;
  /** Grain fill. */
  grainColor?: string;
};

type VariantConfig = {
  /** Grains poured per second. */
  pour: number;
  /** How fast a grain travels while rolling down the heap, px per second. */
  roll: number;
  /** Spout width as a fraction of the vessel. Narrower builds a taller heap. */
  spread: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A slow trickle from a wide spout: barely a heap, mostly a rising level.
  subtle: { pour: 55, roll: 55, spread: 0.34 },
  // A visible mound that settles as it fills. All-purpose.
  default: { pour: 95, roll: 85, spread: 0.2 },
  // A quicker pour through a narrow spout, so grains tumble further.
  playful: { pour: 155, roll: 120, spread: 0.1 },
};

/** tan(30°). Dry grains stop sliding at about this steepness. */
const REPOSE = 0.577;
/** Height one grain adds, as a fraction of a column's width. */
const RISE = 0.29;
const GRAVITY = 900;
/** Most grains that may be in the air at once. */
const IN_FLIGHT = 26;

type Airborne = {
  x: number;
  y: number;
  vy: number;
  /** 0 falling through the air, 1 rolling down the surface. */
  rolling: boolean;
  tone: number;
};

type Settled = { x: number; y: number; tone: number };

export default function CapacityFill({
  variant = "default",
  value = 0.68,
  capacity = 320,
  grainColor = "#D8B87C",
}: CapacityFillProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  // Read through a ref so a new reading never rebuilds the pile — and
  // written in an effect, never during render.
  const valueRef = useRef(value);
  useEffect(() => {
    valueRef.current = value;
  }, [value]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const context = canvas.getContext("2d");
    if (!context) return;

    const config = VARIANTS[variant];
    const wantedCapacity = Math.max(24, Math.round(capacity));
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const buffer = document.createElement("canvas");
    const bufferContext = buffer.getContext("2d");
    if (!bufferContext) return;

    let width = 0;
    let height = 0;
    let ratio = 1;
    let ink = "#888888";

    // Geometry, all derived from the measured box.
    let padTop = 18;
    let leftWall = 8;
    let rightWall = 0;
    let floorY = 0;
    let cell = 12;
    let quantum = 3;
    let grain = 6;
    let columns = 8;
    let total = wantedCapacity;

    let heights: number[] = [];
    let stacks: Settled[][] = [];
    let air: Airborne[] = [];
    let settled = 0;
    let pourDebt = 0;
    let bufferDirty = false;

    const random = (min: number, max: number) => min + Math.random() * (max - min);

    const columnAt = (x: number) =>
      Math.max(0, Math.min(columns - 1, Math.floor((x - leftWall) / cell)));

    const surfaceY = (column: number) => floorY - heights[column] - grain * 0.5;

    const layout = () => {
      const rect = canvas.getBoundingClientRect();
      ratio = Math.min(window.devicePixelRatio || 1, 2);
      width = rect.width;
      height = rect.height;
      canvas.width = Math.max(1, Math.floor(width * ratio));
      canvas.height = Math.max(1, Math.floor(height * ratio));
      buffer.width = canvas.width;
      buffer.height = canvas.height;
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
      bufferContext.setTransform(ratio, 0, 0, ratio, 0, 0);
      ink = getComputedStyle(canvas).color || "#888888";

      padTop = Math.min(22, height * 0.12);
      leftWall = Math.max(6, width * 0.06);
      rightWall = width - leftWall;
      floorY = height - Math.max(4, height * 0.03);

      const interiorWidth = rightWall - leftWall;
      const interiorHeight = floorY - padTop;
      // Column width solved from the requested capacity, so the grain
      // count is what the metadata says at any size the box happens to be.
      cell = Math.sqrt((interiorWidth * interiorHeight) / (RISE * wantedCapacity));
      columns = Math.max(4, Math.round(interiorWidth / cell));
      cell = interiorWidth / columns;
      quantum = RISE * cell;
      // Area-conserving: a grain covers what it adds to its column.
      // Trimmed slightly so neighbours read as grains rather than merge.
      grain = Math.sqrt(quantum * cell) * 0.92;
      total = Math.max(24, Math.round((columns * interiorHeight) / quantum));
    };

    const reset = () => {
      heights = new Array(columns).fill(0);
      stacks = Array.from({ length: columns }, () => []);
      air = [];
      settled = 0;
      pourDebt = 0;
      bufferDirty = true;
    };

    /**
     * The whole model, in one rule: roll on while some neighbour stands
     * more than the repose slope below, and stop when none does.
     */
    const downhill = (column: number) => {
      const threshold = REPOSE * cell;
      const left = column > 0 ? heights[column] - heights[column - 1] : -Infinity;
      const right =
        column < columns - 1 ? heights[column] - heights[column + 1] : -Infinity;
      if (left > threshold && left >= right) return -1;
      if (right > threshold) return 1;
      return 0;
    };

    const paintGrain = (surface: CanvasRenderingContext2D, item: Settled) => {
      surface.globalAlpha = 0.72 + item.tone * 0.28;
      surface.beginPath();
      surface.arc(item.x, item.y, grain * 0.5, 0, Math.PI * 2);
      surface.fill();
    };

    const deposit = (x: number, tone: number) => {
      const column = columnAt(x);
      // Nestled, not stacked: successive grains in a column sit to
      // alternate sides, which is how round grains actually pack and
      // what stops a column reading as a string of beads.
      const row = stacks[column].length;
      const item: Settled = {
        x:
          leftWall +
          (column + 0.5) * cell +
          (row % 2 === 0 ? -1 : 1) * cell * 0.2 +
          random(-1, 1) * cell * 0.1,
        y: floorY - heights[column] - grain * 0.5,
        tone,
      };
      heights[column] += quantum;
      stacks[column].push(item);
      settled++;
      // Incremental: one arrival costs one arc, not a repaint of the pile.
      bufferContext.fillStyle = grainColor;
      paintGrain(bufferContext, item);
      bufferContext.globalAlpha = 1;
    };

    const repaintBuffer = () => {
      bufferContext.clearRect(0, 0, width, height);
      bufferContext.fillStyle = grainColor;
      for (const column of stacks) for (const item of column) paintGrain(bufferContext, item);
      bufferContext.globalAlpha = 1;
      bufferDirty = false;
    };

    const removeTopGrain = () => {
      let tallest = 0;
      for (let index = 1; index < columns; index++) {
        if (heights[index] > heights[tallest]) tallest = index;
      }
      if (!stacks[tallest].length) return;
      stacks[tallest].pop();
      heights[tallest] = Math.max(0, heights[tallest] - quantum);
      settled--;
      bufferDirty = true;
    };

    const emit = () => {
      const spout = (leftWall + rightWall) / 2;
      const half = (rightWall - leftWall) * config.spread * 0.5;
      air.push({
        x: spout + random(-half, half),
        y: random(-4, padTop * 0.5),
        vy: random(20, 70),
        rolling: false,
        tone: Math.random(),
      });
    };

    const step = (delta: number) => {
      const wanted = Math.round(
        Math.min(1, Math.max(0, valueRef.current)) * total
      );

      if (settled > wanted) {
        // Draining: material is taken off the top, which is where it
        // would actually go.
        let budget = Math.max(1, Math.round(config.pour * delta));
        while (budget-- > 0 && settled > wanted) removeTopGrain();
      } else {
        pourDebt += config.pour * delta;
        while (
          pourDebt >= 1 &&
          air.length < IN_FLIGHT &&
          settled + air.length < wanted
        ) {
          pourDebt -= 1;
          emit();
        }
        // Never bank more than a frame's worth of pour, or a pause at
        // the flight cap turns into a burst the moment it clears.
        pourDebt = Math.min(pourDebt, 2);
      }

      for (let index = air.length - 1; index >= 0; index--) {
        const item = air[index];
        if (!item.rolling) {
          item.vy += GRAVITY * delta;
          item.y += item.vy * delta;
          const rest = surfaceY(columnAt(item.x));
          if (item.y >= rest) {
            item.y = rest;
            item.rolling = true;
          }
          continue;
        }

        const column = columnAt(item.x);
        const direction = downhill(column);
        if (direction === 0) {
          deposit(item.x, item.tone);
          air.splice(index, 1);
          continue;
        }
        item.x = Math.max(
          leftWall + 0.5,
          Math.min(rightWall - 0.5, item.x + direction * config.roll * delta)
        );
        item.y = surfaceY(columnAt(item.x));
      }
    };

    const render = () => {
      if (bufferDirty) repaintBuffer();
      context.clearRect(0, 0, width, height);

      // The vessel: an open-topped outline, so the pour has somewhere to
      // come from and the level has something to be read against.
      const radius = Math.min(14, (rightWall - leftWall) * 0.16);
      context.strokeStyle = ink;
      context.globalAlpha = 0.34;
      context.lineWidth = 1.5;
      context.beginPath();
      context.moveTo(leftWall, padTop * 0.6);
      context.lineTo(leftWall, floorY - radius);
      context.quadraticCurveTo(leftWall, floorY, leftWall + radius, floorY);
      context.lineTo(rightWall - radius, floorY);
      context.quadraticCurveTo(rightWall, floorY, rightWall, floorY - radius);
      context.lineTo(rightWall, padTop * 0.6);
      context.stroke();

      context.globalAlpha = 1;
      context.drawImage(buffer, 0, 0, width, height);

      context.fillStyle = grainColor;
      for (const item of air) {
        context.globalAlpha = item.rolling ? 0.9 : 0.75;
        context.beginPath();
        context.arc(item.x, item.y, grain * 0.5, 0, Math.PI * 2);
        context.fill();
      }
      context.globalAlpha = 1;
    };

    layout();
    reset();

    /** Run the deposition to the reported level with no frames spent. */
    const settleInstantly = () => {
      const wanted = Math.round(Math.min(1, Math.max(0, valueRef.current)) * total);
      const guard = total * 2;
      let steps = 0;
      while (settled < wanted && steps++ < guard) {
        const spout = (leftWall + rightWall) / 2;
        const half = (rightWall - leftWall) * config.spread * 0.5;
        let column = columnAt(spout + random(-half, half));
        for (let hop = 0; hop < 200; hop++) {
          const direction = downhill(column);
          if (direction === 0) break;
          column += direction;
        }
        deposit(leftWall + (column + 0.5) * cell, Math.random());
      }
      while (settled > wanted) removeTopGrain();
    };

    // Reduced motion: the same pile at the same level, arrived at
    // without the pour. The heap, its repose flanks and the level it has
    // reached are the whole readout, and all of them survive stillness.
    if (reduced) {
      let shown = Math.min(1, Math.max(0, valueRef.current));
      settleInstantly();
      render();
      const poll = window.setInterval(() => {
        const next = Math.min(1, Math.max(0, valueRef.current));
        if (Math.abs(next - shown) > 0.005) {
          shown = next;
          settleInstantly();
          render();
        }
      }, 250);
      const onResizeStill = () => {
        layout();
        reset();
        settleInstantly();
        render();
      };
      window.addEventListener("resize", onResizeStill);
      return () => {
        window.clearInterval(poll);
        window.removeEventListener("resize", onResizeStill);
      };
    }

    let frame = 0;
    let lastDrawn = -1;
    let last = performance.now();

    const tick = (now: number) => {
      const delta = Math.min((now - last) / 1000, 0.05);
      last = now;
      step(delta);
      // A vessel at its reported level with nothing in the air is a
      // still image. Keep watching for a new reading, but stop
      // repainting a picture that is not changing.
      if (air.length > 0 || bufferDirty || settled !== lastDrawn) {
        lastDrawn = settled;
        render();
      }
      frame = requestAnimationFrame(tick);
    };

    frame = requestAnimationFrame(tick);

    const onResize = () => {
      layout();
      reset();
      settleInstantly();
      render();
    };
    window.addEventListener("resize", onResize);

    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener("resize", onResize);
    };
  }, [variant, capacity, grainColor]);

  return (
    <canvas
      ref={canvasRef}
      role="img"
      aria-label={`Storage ${Math.round(Math.min(1, Math.max(0, value)) * 100)} percent full`}
      style={{ width: "100%", height: "100%", display: "block" }}
    />
  );
}

About this effect

A gauge for anything with a ceiling — storage used, seats taken, a quota burning down. The levelling is a consequence rather than an animation: a grain rolls on while any neighbouring column stands more than the repose slope below it, so the surface can never exceed about thirty degrees. That rule caps the height difference across the vessel, peak to wall, and the cap is a constant — measured at 26px whatever the fill, against a depth that grows from 14px to 167px — so unevenness runs from nearly twice the depth at a tenth full to a sixth of it at the brim, with nothing smoothing the top as the level rises. The same arithmetic chooses the shape: the cap is a fraction of the width, so a tall narrow gauge levels and a wide tray never does, which is why this one is a column. A grain raises its column by less than a third of the column's width, which is what makes the repose slope a real angle rather than a staircase, and the settled pile is blitted from a buffer so a full vessel costs no more per frame than an empty one.

Storage used gaugeQuota or seat allocationBatch import progressCapacity planning tile

Related effects