The pulsing dashed rings around the example workflow read as a second, broken frame. The panel now sheds a few digits at its left edge like the other frames on light backgrounds, and the ring motion, which nothing else used, is removed from BitAura. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
|
|
type AuraSource = "top" | "right" | "bottom" | "left" | "around";
|
|
type AuraTone = "dark" | "light";
|
|
/** How the energy behaves: `sparks` drift off the edge, `rain` falls onto it. */
|
|
type AuraMotion = "sparks" | "rain";
|
|
|
|
interface BitAuraProps {
|
|
/** The edge the energy sits on, or `around` to let it travel round the element. */
|
|
source: AuraSource;
|
|
/** The background the element stands on: `dark` adds light, `light` only ink digits. */
|
|
tone: AuraTone;
|
|
motion?: AuraMotion;
|
|
/** Share of the usual amount of digits (1 = default). */
|
|
density?: number;
|
|
/** Corner radius of the lit element in px. */
|
|
radius?: number;
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
interface Box {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
/** A bright zone on the outline, placed by its position along the perimeter (0..1). */
|
|
interface Spot {
|
|
at: number;
|
|
power: number;
|
|
}
|
|
|
|
interface Digit {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
age: number;
|
|
life: number;
|
|
seed: number;
|
|
power: number;
|
|
large: boolean;
|
|
/** Outward normal at the point of the outline the digit belongs to. */
|
|
nx: number;
|
|
ny: number;
|
|
}
|
|
|
|
interface Palette {
|
|
blend: GlobalCompositeOperation;
|
|
glow: string;
|
|
core: string;
|
|
digit: string;
|
|
gain: number;
|
|
/** Share of the digit sparks that are kept (1 = all). */
|
|
density: number;
|
|
}
|
|
|
|
const MARGIN = 180;
|
|
const FRAME_MS = 1000 / 30;
|
|
const MAX_STEP_S = 0.1;
|
|
const MAX_PIXEL_RATIO = 2;
|
|
/** `rounded-3xl` in this project's Tailwind theme. */
|
|
const DEFAULT_RADIUS = 32;
|
|
const STILL_FRAME_STEPS = 90;
|
|
|
|
/** The glow is the outline stroked from wide and faint to thin and bright. */
|
|
const GLOW_LAYERS = [
|
|
{ width: 150, alpha: 0.035, hot: false },
|
|
{ width: 110, alpha: 0.05, hot: false },
|
|
{ width: 78, alpha: 0.07, hot: false },
|
|
{ width: 52, alpha: 0.1, hot: false },
|
|
{ width: 32, alpha: 0.15, hot: false },
|
|
{ width: 18, alpha: 0.22, hot: false },
|
|
{ width: 7, alpha: 0.45, hot: true },
|
|
{ width: 2, alpha: 0.95, hot: true },
|
|
] as const;
|
|
|
|
const LIGHT_PALETTE: Palette = {
|
|
blend: "source-over",
|
|
glow: "15, 23, 42",
|
|
core: "15, 23, 42",
|
|
digit: "15, 23, 42",
|
|
// No haze on light backgrounds: coloured light only reads on dark. Ink digits only.
|
|
gain: 0,
|
|
density: 0.6,
|
|
};
|
|
|
|
const DARK_PALETTE: Palette = {
|
|
blend: "lighter",
|
|
glow: "99, 102, 241",
|
|
core: "224, 231, 255",
|
|
digit: "199, 210, 254",
|
|
gain: 1,
|
|
density: 1,
|
|
};
|
|
|
|
const MOTION_DENSITY: Record<AuraMotion, number> = {
|
|
sparks: 0.45,
|
|
rain: 0.3,
|
|
};
|
|
|
|
/** How often a drifting digit switches between 0 and 1. */
|
|
const FLIPS_PER_S = 0.9;
|
|
|
|
const fraction = (value: number) => value - Math.floor(value);
|
|
|
|
const perimeter = ({ width, height }: Box) => 2 * (width + height);
|
|
|
|
/** Point and outward normal on the outline, walking clockwise from the top left corner. */
|
|
const outlinePoint = (at: number, { width, height }: Box) => {
|
|
let distance = fraction(at) * 2 * (width + height);
|
|
if (distance < width) return { x: distance, y: 0, nx: 0, ny: -1 };
|
|
distance -= width;
|
|
if (distance < height) return { x: width, y: distance, nx: 1, ny: 0 };
|
|
distance -= height;
|
|
if (distance < width) return { x: width - distance, y: height, nx: 0, ny: 1 };
|
|
distance -= width;
|
|
return { x: 0, y: height - distance, nx: -1, ny: 0 };
|
|
};
|
|
|
|
/** The stretch of the perimeter (0..1) that one edge covers. */
|
|
const edgeRange = (
|
|
source: Exclude<AuraSource, "around">,
|
|
box: Box,
|
|
): readonly [number, number] => {
|
|
const total = perimeter(box);
|
|
const top = box.width / total;
|
|
const right = (box.width + box.height) / total;
|
|
const bottom = (2 * box.width + box.height) / total;
|
|
if (source === "top") return [0, top];
|
|
if (source === "right") return [top, right];
|
|
if (source === "bottom") return [right, bottom];
|
|
return [bottom, 1];
|
|
};
|
|
|
|
/** Where the energy is at time `t` (seconds): it wanders along one edge or circles the element. */
|
|
const spotsAt = (
|
|
source: AuraSource,
|
|
motion: AuraMotion,
|
|
t: number,
|
|
box: Box,
|
|
): readonly Spot[] => {
|
|
if (source === "around") {
|
|
return [
|
|
{ at: fraction(t * 0.03), power: 1 },
|
|
{ at: fraction(t * 0.03 + 0.5), power: 0.7 },
|
|
{ at: fraction(1.2 - t * 0.018), power: 0.5 },
|
|
];
|
|
}
|
|
const [from, to] = edgeRange(source, box);
|
|
const along = (share: number) => from + (to - from) * share;
|
|
return [
|
|
{
|
|
at: along(0.5 + 0.32 * Math.sin(t * 0.5)),
|
|
power: 0.85 + 0.15 * Math.sin(t * 0.9),
|
|
},
|
|
{ at: along(0.5 + 0.4 * Math.sin(t * 0.31 + 2)), power: 0.6 },
|
|
{
|
|
at: along(0.5 + 0.45 * Math.sin(t * 0.83 + 4)),
|
|
power: 0.35 + 0.2 * Math.sin(t * 1.3),
|
|
},
|
|
];
|
|
};
|
|
|
|
const spawnDigit = (
|
|
spots: readonly Spot[],
|
|
motion: AuraMotion,
|
|
box: Box,
|
|
): Digit => {
|
|
const total = spots.reduce((sum, spot) => sum + spot.power, 0);
|
|
let pick = Math.random() * total;
|
|
const spot =
|
|
spots.find((candidate) => (pick -= candidate.power) <= 0) ?? spots[0];
|
|
const power = spot?.power ?? 1;
|
|
const base = {
|
|
age: 0,
|
|
seed: Math.random(),
|
|
power,
|
|
large: Math.random() < 0.22,
|
|
};
|
|
|
|
const wide = motion === "rain" ? 0.12 : 0.07;
|
|
const scatter = (Math.random() + Math.random() + Math.random() - 1.5) * wide;
|
|
const point = outlinePoint((spot?.at ?? 0) + scatter, box);
|
|
|
|
if (motion === "rain") {
|
|
const distance = 40 + Math.random() * 80;
|
|
const speed = 22 + Math.random() * 26;
|
|
return {
|
|
...base,
|
|
x: point.x + point.nx * distance,
|
|
y: point.y + point.ny * distance,
|
|
vx: -point.nx * speed,
|
|
vy: -point.ny * speed,
|
|
nx: point.nx,
|
|
ny: point.ny,
|
|
life: distance / speed,
|
|
};
|
|
}
|
|
|
|
const speed = 10 + Math.random() * 24;
|
|
const slide = (Math.random() - 0.5) * 12;
|
|
return {
|
|
...base,
|
|
x: point.x,
|
|
y: point.y,
|
|
vx: point.nx * speed - point.ny * slide,
|
|
vy: point.ny * speed + point.nx * slide,
|
|
nx: point.nx,
|
|
ny: point.ny,
|
|
life: 2.4 + Math.random() * 2.6,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* BitAura: energy that hugs the outline of the element it wraps. The glow flows
|
|
* along the edge and binary digits drift off it like sparks. Decorative only:
|
|
* it runs while in view and stands still when reduced motion is requested.
|
|
*/
|
|
export const BitAura: React.FC<BitAuraProps> = ({
|
|
source,
|
|
tone,
|
|
motion = "sparks",
|
|
density = 1,
|
|
radius = DEFAULT_RADIUS,
|
|
className = "",
|
|
children,
|
|
}) => {
|
|
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
|
|
|
React.useEffect(() => {
|
|
const wrapper = wrapperRef.current;
|
|
const canvas = canvasRef.current;
|
|
const context = canvas?.getContext("2d");
|
|
if (!wrapper || !canvas || !context) return;
|
|
|
|
const palette = tone === "light" ? LIGHT_PALETTE : DARK_PALETTE;
|
|
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
const fontFamily = window.getComputedStyle(canvas).fontFamily;
|
|
let box: Box = { width: 0, height: 0 };
|
|
let digits: Digit[] = [];
|
|
let owed = 0;
|
|
let clock = Math.random() * 40;
|
|
let last = 0;
|
|
let frame = 0;
|
|
let visible = false;
|
|
|
|
const step = (seconds: number) => {
|
|
clock += seconds;
|
|
const spots = spotsAt(source, motion, clock, box);
|
|
const rate =
|
|
Math.min(90, Math.max(28, perimeter(box) / 30)) *
|
|
palette.density *
|
|
MOTION_DENSITY[motion] *
|
|
density;
|
|
owed += rate * seconds;
|
|
while (owed >= 1) {
|
|
digits.push(spawnDigit(spots, motion, box));
|
|
owed -= 1;
|
|
}
|
|
const drag = 1 - 0.6 * seconds;
|
|
digits = digits.filter((digit) => {
|
|
digit.age += seconds;
|
|
if (motion !== "rain") {
|
|
digit.vx *= drag;
|
|
digit.vy = digit.vy * drag - 7 * seconds;
|
|
}
|
|
digit.x += digit.vx * seconds;
|
|
digit.y += digit.vy * seconds;
|
|
return digit.age < digit.life;
|
|
});
|
|
return spots;
|
|
};
|
|
|
|
const draw = (spots: readonly Spot[]) => {
|
|
const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
|
|
context.setTransform(ratio, 0, 0, ratio, MARGIN * ratio, MARGIN * ratio);
|
|
context.clearRect(
|
|
-MARGIN,
|
|
-MARGIN,
|
|
box.width + 2 * MARGIN,
|
|
box.height + 2 * MARGIN,
|
|
);
|
|
context.globalCompositeOperation = palette.blend;
|
|
context.globalAlpha = 1;
|
|
|
|
context.beginPath();
|
|
context.roundRect(0, 0, box.width, box.height, radius);
|
|
const reach = Math.min(560, Math.max(220, perimeter(box) * 0.2));
|
|
for (const spot of palette.gain > 0 ? spots : []) {
|
|
const point = outlinePoint(spot.at, box);
|
|
for (const layer of GLOW_LAYERS) {
|
|
const colour = layer.hot ? palette.core : palette.glow;
|
|
const light = context.createRadialGradient(
|
|
point.x,
|
|
point.y,
|
|
0,
|
|
point.x,
|
|
point.y,
|
|
reach,
|
|
);
|
|
light.addColorStop(
|
|
0,
|
|
`rgba(${colour}, ${layer.alpha * spot.power * palette.gain})`,
|
|
);
|
|
light.addColorStop(1, `rgba(${colour}, 0)`);
|
|
context.strokeStyle = light;
|
|
context.lineWidth = layer.width;
|
|
context.stroke();
|
|
}
|
|
}
|
|
|
|
context.fillStyle = `rgb(${palette.digit})`;
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
for (const large of [false, true]) {
|
|
context.font = `${large ? 700 : 500} ${large ? 14 : 11}px ${fontFamily}`;
|
|
for (const digit of digits) {
|
|
if (digit.large !== large) continue;
|
|
const fade = Math.sin((Math.PI * digit.age) / digit.life);
|
|
context.globalAlpha = Math.min(1, fade * digit.power * 1.1);
|
|
const one =
|
|
Math.floor(digit.age * FLIPS_PER_S + digit.seed * 10) % 2 === 0;
|
|
context.fillText(one ? "1" : "0", digit.x, digit.y);
|
|
}
|
|
}
|
|
};
|
|
|
|
const resize = () => {
|
|
const rect = wrapper.getBoundingClientRect();
|
|
box = { width: rect.width, height: rect.height };
|
|
const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
|
|
canvas.width = Math.round((box.width + 2 * MARGIN) * ratio);
|
|
canvas.height = Math.round((box.height + 2 * MARGIN) * ratio);
|
|
if (still) {
|
|
digits = [];
|
|
let spots = spotsAt(source, motion, clock, box);
|
|
for (let i = 0; i < STILL_FRAME_STEPS; i++)
|
|
spots = step(FRAME_MS / 1000);
|
|
draw(spots);
|
|
}
|
|
};
|
|
|
|
const tick = (now: number) => {
|
|
frame = window.requestAnimationFrame(tick);
|
|
if (now - last < FRAME_MS) return;
|
|
const seconds = Math.min(MAX_STEP_S, (now - last) / 1000);
|
|
last = now;
|
|
draw(step(seconds));
|
|
};
|
|
|
|
const sizeObserver = new ResizeObserver(resize);
|
|
sizeObserver.observe(wrapper);
|
|
resize();
|
|
|
|
const viewObserver = new IntersectionObserver(
|
|
([entry]) => {
|
|
const next = entry?.isIntersecting ?? false;
|
|
if (next === visible || still) return;
|
|
visible = next;
|
|
if (visible) {
|
|
last = performance.now();
|
|
frame = window.requestAnimationFrame(tick);
|
|
} else {
|
|
window.cancelAnimationFrame(frame);
|
|
}
|
|
},
|
|
{ rootMargin: `${MARGIN}px` },
|
|
);
|
|
viewObserver.observe(wrapper);
|
|
|
|
return () => {
|
|
sizeObserver.disconnect();
|
|
viewObserver.disconnect();
|
|
window.cancelAnimationFrame(frame);
|
|
};
|
|
}, [source, tone, motion, density, radius]);
|
|
|
|
return (
|
|
<div
|
|
ref={wrapperRef}
|
|
// The wrapper has to be positioned; keep the caller's own positioning if it brings one.
|
|
className={
|
|
className.split(" ").includes("absolute")
|
|
? className
|
|
: `relative ${className}`
|
|
}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
aria-hidden="true"
|
|
className="bit-aura pointer-events-none absolute font-mono"
|
|
style={{
|
|
left: -MARGIN,
|
|
top: -MARGIN,
|
|
width: `calc(100% + ${2 * MARGIN}px)`,
|
|
height: `calc(100% + ${2 * MARGIN}px)`,
|
|
}}
|
|
/>
|
|
<div className="relative h-full">{children}</div>
|
|
</div>
|
|
);
|
|
};
|