Constellation

Nodes and links, rebuilt to the standard: nodes deflect off measured boxes and every link is clipped exactly at the boundary.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/constellation.json
Backdrop · canvasobstacle-aware
Links never cross your headline.
measured, not guessed

Constellationesc or double-click to exit full screendouble-tap to exit

atmosphere/constellation.tsx
"use client";

import * as React from "react";
import {
  AVOID_SELECTOR,
  cx,
  DEFAULT_LANTERN,
  DEFAULT_WISP,
  deflect,
  hsl,
  inAnyBox,
  segmentHitsAny,
  useCanvasScene,
  useObstacles,
  useTokenColor,
  useWispGain,
  type Hsl,
} from "@pouriahlabs/wisp-ui";

/**
 * Constellation — the familiar nodes-and-links field, rebuilt to the standard.
 *
 * The effect itself is everywhere. What isn't: nodes deflect off measured
 * content boxes instead of drifting under them, and every link is tested
 * against those boxes with an exact segment clip, so a line is never drawn
 * across a word. A sampled test misses short crossings near a corner, and a
 * link flickering through the edge of a headline is precisely the artefact this
 * library exists to prevent.
 */

export interface ConstellationProps extends React.HTMLAttributes<HTMLDivElement> {
  fromToken?: string;
  toToken?: string;
  /** Selector for content nodes must not cross. */
  avoid?: string;
  /** Horizontal breathing room around measured content, CSS px. */
  padX?: number;
  /** Vertical breathing room around measured content, CSS px. */
  padY?: number;
  /** Roughly one node per this much area, px². */
  density?: number;
  /** Link nodes closer than this, px. */
  linkDistance?: number;
  /** Fraction of nodes drawn in the `toToken` accent. */
  accentRatio?: number;
  /** Drift-speed multiplier. `1` is the default pace. */
  speed?: number;
  /** Draw the measured boxes as wireframes. Debugging aid — and a good demo. */
  debugBoxes?: boolean;
}

interface Node {
  x: number;
  y: number;
  vx: number;
  vy: number;
  radius: number;
  accent: boolean;
}

export function Constellation({
  fromToken = "--wisp",
  toToken = "--lantern",
  avoid = AVOID_SELECTOR,
  padX = 8,
  padY = 6,
  density = 3400,
  linkDistance = 90,
  accentRatio = 0.22,
  speed = 1,
  debugBoxes = false,
  className,
  ...props
}: ConstellationProps) {
  const ref = React.useRef<HTMLCanvasElement>(null);
  const boxes = useObstacles(ref, avoid, { padX, padY });

  const base = useTokenColor(fromToken, DEFAULT_WISP);
  const accent = useTokenColor(toToken, DEFAULT_LANTERN);
  const gain = useWispGain();

  const nodes = React.useRef<Node[]>([]);
  // Each node's glow is one radial gradient; allocating ~90 of them per frame is
  // the scene's dominant cost. Bake one sprite per colour instead and stamp it
  // with `drawImage`, rebuilding only when a theme swap moves the tokens.
  const sprites = React.useRef<{ key: string; base: HTMLCanvasElement; accent: HTMLCanvasElement } | null>(
    null,
  );

  useCanvasScene(ref, {
    setup: ({ width, height }) => {
      // The cap is a cost ceiling, not a look: linking is O(n²), and ~90 nodes
      // is ~4k pair tests a frame, which stays cheap. Below it, area decides —
      // so a card, a hero and a full-screen view all read at the same density.
      const count = Math.min(90, Math.max(12, Math.round((width * height) / density)));
      nodes.current = Array.from({ length: count }, () =>
        spawn(width, height, accentRatio, boxes.current),
      );
    },

    draw: ({ ctx, width, height, dt }) => {
      ctx.clearRect(0, 0, width, height);
      const maxDistanceSq = linkDistance * linkDistance;

      for (const node of nodes.current) {
        node.x += node.vx * dt * speed;
        node.y += node.vy * dt * speed;

        if (node.x < 4) {
          node.x = 4;
          node.vx = Math.abs(node.vx);
        } else if (node.x > width - 4) {
          node.x = width - 4;
          node.vx = -Math.abs(node.vx);
        }
        if (node.y < 4) {
          node.y = 4;
          node.vy = Math.abs(node.vy);
        } else if (node.y > height - 4) {
          node.y = height - 4;
          node.vy = -Math.abs(node.vy);
        }

        // Deflect off measured content along the shortest axis out.
        for (const box of boxes.current) {
          if (node.x <= box.l || node.x >= box.r || node.y <= box.t || node.y >= box.b) continue;
          const out = deflect(box, node.x, node.y);
          node.x = out.x;
          node.y = out.y;
          if (out.axis === "x") node.vx = out.sign * Math.abs(node.vx);
          else node.vy = out.sign * Math.abs(node.vy);
          break;
        }
      }

      // Links, clipped at every content boundary.
      ctx.lineWidth = 1;
      for (let i = 0; i < nodes.current.length; i++) {
        const a = nodes.current[i]!;
        for (let j = i + 1; j < nodes.current.length; j++) {
          const b = nodes.current[j]!;
          const dx = a.x - b.x;
          const dy = a.y - b.y;
          const distanceSq = dx * dx + dy * dy;
          if (distanceSq > maxDistanceSq) continue;
          if (segmentHitsAny(boxes.current, a.x, a.y, b.x, b.y)) continue;

          const alpha = (1 - Math.sqrt(distanceSq) / linkDistance) * 0.42 * gain;
          ctx.strokeStyle = hsl(base, base[2], alpha);
          ctx.beginPath();
          ctx.moveTo(a.x, a.y);
          ctx.lineTo(b.x, b.y);
          ctx.stroke();
        }
      }

      ctx.globalCompositeOperation = "lighter";
      const key = `${base.join()}|${accent.join()}`;
      if (!sprites.current || sprites.current.key !== key) {
        sprites.current = { key, base: glowSprite(base), accent: glowSprite(accent) };
      }
      for (const node of nodes.current) {
        const sprite = node.accent ? sprites.current.accent : sprites.current.base;
        const radius = node.radius * 4;
        // Alpha and radius were baked at 1; scale and fade per node here.
        ctx.globalAlpha = 0.85 * gain;
        ctx.drawImage(sprite, node.x - radius, node.y - radius, radius * 2, radius * 2);
      }
      ctx.globalAlpha = 1;
      ctx.globalCompositeOperation = "source-over";

      if (debugBoxes) {
        ctx.save();
        ctx.lineWidth = 1;
        ctx.font = "600 9px ui-monospace, SFMono-Regular, Menlo, monospace";
        for (const box of boxes.current) {
          ctx.setLineDash([4, 4]);
          ctx.strokeStyle = hsl(accent, accent[2], 0.85);
          ctx.fillStyle = hsl(accent, accent[2], 0.05);
          ctx.fillRect(box.l, box.t, box.r - box.l, box.b - box.t);
          ctx.strokeRect(box.l + 0.5, box.t + 0.5, box.r - box.l - 1, box.b - box.t - 1);
          ctx.setLineDash([]);
          ctx.fillStyle = hsl(accent, accent[2], 0.95);
          ctx.fillText(avoid, box.l + 4, box.t - 4);
        }
        ctx.restore();
      }
    },
  });

  return (
    <div
      aria-hidden
      className={cx("pointer-events-none absolute inset-0 overflow-hidden", className)}
      {...props}
    >
      <canvas ref={ref} className="absolute inset-0 h-full w-full" />
    </div>
  );
}

/** Radius the glow is baked at. Nodes are far smaller, so it only ever scales
 *  down, where bilinear sampling stays clean. */
const GLOW_RADIUS = 48;

/** Bake a node's radial glow — bright core to transparent edge — into an
 *  offscreen canvas once, at full alpha, so the draw loop can stamp it. */
function glowSprite(color: Hsl): HTMLCanvasElement {
  const canvas = document.createElement("canvas");
  canvas.width = canvas.height = GLOW_RADIUS * 2;
  const g = canvas.getContext("2d")!;
  const grad = g.createRadialGradient(
    GLOW_RADIUS,
    GLOW_RADIUS,
    0,
    GLOW_RADIUS,
    GLOW_RADIUS,
    GLOW_RADIUS,
  );
  grad.addColorStop(0, hsl(color, color[2] + 24, 1));
  grad.addColorStop(1, hsl(color, color[2], 0));
  g.fillStyle = grad;
  g.fillRect(0, 0, GLOW_RADIUS * 2, GLOW_RADIUS * 2);
  return canvas;
}

function spawn(
  width: number,
  height: number,
  accentRatio: number,
  boxes: ReadonlyArray<{ l: number; r: number; t: number; b: number }>,
): Node {
  let x = 0;
  let y = 0;
  // Never seed a node inside content; give up after a bounded search so a
  // layout with almost no free space can't spin here.
  for (let tries = 0; tries < 24; tries++) {
    x = Math.random() * width;
    y = Math.random() * height;
    if (!inAnyBox(boxes, x, y)) break;
  }

  return {
    x,
    y,
    vx: (Math.random() - 0.5) * 20,
    vy: (Math.random() - 0.5) * 20,
    radius: 1.1 + Math.random() * 1.5,
    accent: Math.random() < accentRatio,
  };
}

Certified against the contract