Copy URL or Code
Paste to your AI coding assistant and say:
“Add these wandering dots to my website”
Done. Your AI handles the rest.
Fully customizable. These moods are starting points — ask your AI to adapt the motion to express whatever meaning you want to convey.
"use client";
import React, { useRef, useEffect, useState, useCallback } from "react";
export type AnimationMode = "lattice" | "discover" | "invoke" | "pay" | "trust";
export type ThemeVariant = "dark" | "light";
export interface WanderingDotsTheme {
accent?: string;
pink?: string;
purple?: string;
orange?: string;
background?: string;
centerDot?: string;
}
export interface WanderingDotsProps {
mode?: AnimationMode;
variant?: ThemeVariant;
theme?: WanderingDotsTheme;
size?: number;
className?: string;
onModeChange?: (mode: AnimationMode) => void;
}
const darkTheme: Required<WanderingDotsTheme> = {
accent: "#00F0B5", pink: "#FF79C6", purple: "#BD93F9", orange: "#FFB86C", background: "#0a0a0a", centerDot: "#1a1a1a",
};
const lightTheme: Required<WanderingDotsTheme> = {
accent: "#00A67E", pink: "#E91E8C", purple: "#8B5CF6", orange: "#F97316", background: "#ffffff", centerDot: "#e5e5e5",
};
export default function WanderingDots({
mode: controlledMode,
variant = "dark",
theme: customTheme,
size = 400,
className = "",
onModeChange,
}: WanderingDotsProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>();
const [internalMode, setInternalMode] = useState<AnimationMode>("lattice");
const mode = controlledMode ?? internalMode;
const baseTheme = variant === "light" ? lightTheme : darkTheme;
const theme = { ...baseTheme, ...customTheme };
const colorArray = [theme.accent, theme.pink, theme.purple, theme.orange];
const stateRef = useRef({
time: 0,
currentMode: "lattice" as AnimationMode,
targetMode: "lattice" as AnimationMode,
transitionProgress: 1,
mouse: { x: null as number | null, y: null as number | null },
latticeNodes: [] as any[], latticeConnections: [] as any[], latticeDiffuseWaves: [] as any[],
nexusNodes: [] as any[], nexusOrbits: [] as any[], nexusPulseWaves: [] as any[],
nexusFirstRipple: true, nexusLastRippleTime: 0,
relayNodes: [] as any[], relayConnections: [] as any[],
scalesLeftNodes: [] as any[], scalesRightNodes: [] as any[], scalesTransactions: [] as any[],
trustNodes: [] as any[], trustConnections: [] as any[],
core: { baseSize: 12, glowSize: 30, pulsePhase: 0 },
exchange: { baseSize: 14, glowSize: 35, pulsePhase: 0, energy: 0 },
});
useEffect(() => { stateRef.current.targetMode = mode; }, [mode]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const state = stateRef.current;
const width = size, height = size;
const centerX = width / 2, centerY = height / 2;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr; canvas.height = height * dpr;
canvas.style.width = `${width}px`; canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
class LatticeDiffuseWave {
sourceX: number; sourceY: number; radius = 0; maxRadius: number; speed = 0.4; life = 1; decay = 0.003;
constructor(node: any) { this.sourceX = node.x; this.sourceY = node.y; this.maxRadius = Math.min(width, height) * 0.6; }
update() { this.radius += this.speed; this.life -= this.decay; return this.life <= 0 || this.radius > this.maxRadius; }
getInfluence(node: any) {
const dx = node.x - this.sourceX, dy = node.y - this.sourceY;
const dist = Math.sqrt(dx*dx + dy*dy), waveWidth = 40, distFromWave = Math.abs(dist - this.radius);
if (distFromWave < waveWidth) return (1 - distFromWave/waveWidth) * Math.max(0, 1 - dist/this.maxRadius) * this.life;
return 0;
}
}
function initLattice() {
state.latticeNodes = []; state.latticeConnections = []; state.latticeDiffuseWaves = [];
const gridSize = Math.min(width, height) * 0.8, layers = 4, nodesPerLayer = [1, 3, 6, 9];
let nodeId = 0;
for (let layer = 0; layer < layers; layer++) {
const count = nodesPerLayer[layer], radius = (layer / (layers - 1)) * (gridSize / 2);
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2 + layer * 0.2;
const jitter = layer > 0 ? (Math.random() - 0.5) * 20 : 0;
state.latticeNodes.push({ id: nodeId++, x: centerX + Math.cos(angle) * radius + jitter, y: centerY + Math.sin(angle) * radius + jitter, baseX: centerX + Math.cos(angle) * radius, baseY: centerY + Math.sin(angle) * radius, layer, size: 4 - layer * 0.5, color: colorArray[layer % colorArray.length], phase: Math.random() * Math.PI * 2, energy: 0, lastSignalTime: 0 });
}
}
const maxDistance = gridSize / 3;
for (let i = 0; i < state.latticeNodes.length; i++) {
for (let j = i + 1; j < state.latticeNodes.length; j++) {
const dx = state.latticeNodes[j].baseX - state.latticeNodes[i].baseX;
const dy = state.latticeNodes[j].baseY - state.latticeNodes[i].baseY;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < maxDistance && Math.abs(state.latticeNodes[i].layer - state.latticeNodes[j].layer) <= 1) {
state.latticeConnections.push({ from: i, to: j, strength: 1 - dist / maxDistance, flowProgress: Math.random(), flowSpeed: 0.002 + Math.random() * 0.003 });
}
}
}
}
function initNexus() {
state.nexusNodes = []; state.nexusOrbits = []; state.nexusPulseWaves = [];
state.nexusFirstRipple = true; state.nexusLastRippleTime = 0;
const baseRadius = Math.min(width, height) * 0.35;
const configs = [{ r: baseRadius * 0.35, n: 3, s: 0.0008, c: theme.accent }, { r: baseRadius * 0.6, n: 5, s: -0.0005, c: theme.pink }, { r: baseRadius * 0.85, n: 7, s: 0.0003, c: theme.purple }];
configs.forEach((cfg, oi) => {
state.nexusOrbits.push({ radius: cfg.r, speed: cfg.s, rotation: Math.random() * Math.PI * 2, color: cfg.c });
for (let i = 0; i < cfg.n; i++) state.nexusNodes.push({ orbitIndex: oi, angle: (i / cfg.n) * Math.PI * 2, size: (3 + Math.random() * 2) * 1.5, color: colorArray[(oi + i) % colorArray.length], phase: Math.random() * Math.PI * 2, energy: 0, brightness: 0.6 });
});
}
function getPentagonVertex(i: number, r: number) { const a = (i * 2 * Math.PI) / 5 - Math.PI / 2; return { x: centerX + Math.cos(a) * r, y: centerY + Math.sin(a) * r }; }
function initRelay() {
state.relayNodes = []; state.relayConnections = [];
const gridSize = Math.min(width, height) * 0.6; let nodeId = 0;
state.relayNodes.push({ id: nodeId++, x: centerX, y: centerY, baseX: centerX, baseY: centerY, layer: 0, size: 8, color: theme.accent, phase: Math.random() * Math.PI * 2, energy: 0 });
[0.25, 0.5].forEach((mult, li) => { const r = gridSize * mult; for (let i = 0; i < 5; i++) { const p = getPentagonVertex(i, r); state.relayNodes.push({ id: nodeId++, x: p.x, y: p.y, baseX: p.x, baseY: p.y, layer: li + 1, size: 7 - li * 0.8, color: colorArray[(li + i) % colorArray.length], phase: Math.random() * Math.PI * 2, energy: 0 }); } });
const maxD = gridSize / 2.5;
for (let i = 0; i < state.relayNodes.length; i++) for (let j = i + 1; j < state.relayNodes.length; j++) {
const dx = state.relayNodes[j].baseX - state.relayNodes[i].baseX, dy = state.relayNodes[j].baseY - state.relayNodes[i].baseY, d = Math.sqrt(dx*dx + dy*dy);
if (d < maxD && Math.abs(state.relayNodes[i].layer - state.relayNodes[j].layer) <= 1) state.relayConnections.push({ from: i, to: j, strength: 1 - d / maxD, flowProgress: Math.random(), active: false });
}
}
function initScales() {
state.scalesLeftNodes = []; state.scalesRightNodes = []; state.scalesTransactions = [];
const spreadX = Math.min(width, height) * 0.32, spreadY = Math.min(width, height) * 0.3;
for (let i = 0; i < 7; i++) { const a = Math.PI * 0.4 + (i / 6) * Math.PI * 1.2, r = spreadY * (0.5 + Math.random() * 0.5), ox = (Math.random() - 0.5) * 20;
state.scalesLeftNodes.push({ x: centerX - spreadX + Math.cos(a) * r * 0.5 + ox, y: centerY + Math.sin(a) * r, baseX: centerX - spreadX + Math.cos(a) * r * 0.5 + ox, baseY: centerY + Math.sin(a) * r, size: 5 + Math.random() * 3, color: theme.accent, phase: Math.random() * Math.PI * 2, energy: 0, brightness: 0.75 }); }
for (let i = 0; i < 7; i++) { const a = -Math.PI * 0.4 + (i / 6) * Math.PI * 1.2, r = spreadY * (0.5 + Math.random() * 0.5), ox = (Math.random() - 0.5) * 20;
state.scalesRightNodes.push({ x: centerX + spreadX + Math.cos(a) * r * 0.5 + ox, y: centerY + Math.sin(a) * r, baseX: centerX + spreadX + Math.cos(a) * r * 0.5 + ox, baseY: centerY + Math.sin(a) * r, size: 5 + Math.random() * 3, color: theme.pink, phase: Math.random() * Math.PI * 2, energy: 0, brightness: 0.75 }); }
}
function initTrust() {
state.trustNodes = []; state.trustConnections = [];
const spread = Math.min(width, height) * 0.35;
for (let i = 0; i < 17; i++) { const a = (i / 17) * Math.PI * 2 + Math.random() * 0.4, d = spread * (0.25 + Math.random() * 0.75), isS = i < 4;
state.trustNodes.push({ x: centerX + Math.cos(a) * d + (Math.random() - 0.5) * 60, y: centerY + Math.sin(a) * d + (Math.random() - 0.5) * 60, baseX: centerX + Math.cos(a) * d, baseY: centerY + Math.sin(a) * d, size: isS ? 7 : 4 + Math.random() * 2, baseTrust: isS ? 0.85 + Math.random() * 0.15 : 0.1 + Math.random() * 0.25, currentTrust: isS ? 0.9 : 0.15, isSource: isS, phase: Math.random() * Math.PI * 2 }); }
const maxD = spread * 0.55;
for (let i = 0; i < state.trustNodes.length; i++) for (let j = i + 1; j < state.trustNodes.length; j++) {
const dx = state.trustNodes[j].baseX - state.trustNodes[i].baseX, dy = state.trustNodes[j].baseY - state.trustNodes[i].baseY, d = Math.sqrt(dx*dx + dy*dy);
if (d < maxD) state.trustConnections.push({ from: i, to: j, strength: 1 - d / maxD, flowProgress: Math.random(), flowDirection: Math.random() > 0.5 ? 1 : -1 });
}
}
function drawLattice(alpha: number) {
const breathe = Math.sin(state.time * 0.00942) * 0.0735 + 1;
state.latticeConnections.forEach((conn) => {
const from = state.latticeNodes[conn.from], to = state.latticeNodes[conn.to];
let waveInf = 0; state.latticeDiffuseWaves.forEach((w: any) => { waveInf = Math.max(waveInf, (w.getInfluence(from) + w.getInfluence(to)) / 2); });
const pp = (state.time * 0.001 + conn.flowProgress) % 1;
let px = from.x + (to.x - from.x) * pp, py = from.y + (to.y - from.y) * pp;
if (state.mouse.x !== null && state.mouse.y !== null) { const dx = px - state.mouse.x, dy = py - state.mouse.y, d = Math.sqrt(dx*dx + dy*dy); if (d < 80 && d > 0) { const s = (1 - d / 80) * 25; px += (dx / d) * s; py += (dy / d) * s; } }
ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.strokeStyle = from.color; ctx.globalAlpha = (conn.strength * 0.3 + waveInf * 0.15) * alpha; ctx.lineWidth = 1 + waveInf * 0.5; ctx.stroke();
ctx.beginPath(); ctx.arc(px, py, 2, 0, Math.PI * 2); ctx.fillStyle = theme.accent; ctx.globalAlpha = (0.9 + waveInf * 0.1) * alpha; ctx.fill();
});
state.latticeNodes.forEach((node) => {
let waveInf = 0; state.latticeDiffuseWaves.forEach((w: any) => { waveInf = Math.max(waveInf, w.getInfluence(node)); });
node.energy = Math.max(node.energy * 0.96, waveInf);
const np = Math.sin(state.time * 0.003 + node.phase) * 0.5 + 0.5, eb = 1 + node.energy * 0.2;
const ns = node.size * (1 + np * 0.5) * breathe * eb, gs = ns * (2.5 + node.energy * 0.75);
const gr = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, gs); gr.addColorStop(0, node.color); gr.addColorStop(0.5, node.color); gr.addColorStop(1, "transparent");
ctx.beginPath(); ctx.arc(node.x, node.y, gs, 0, Math.PI * 2); ctx.fillStyle = gr; ctx.globalAlpha = (0.3 + np * 0.3 + node.energy * 0.3) * alpha; ctx.fill();
ctx.beginPath(); ctx.arc(node.x, node.y, ns, 0, Math.PI * 2); ctx.fillStyle = node.color; ctx.globalAlpha = (0.95 + node.energy * 0.05) * alpha; ctx.fill();
});
}
function drawNexus(alpha: number) {
const ri = state.nexusFirstRipple ? 20 : 240;
if (state.time - state.nexusLastRippleTime >= ri && state.targetMode === "discover") { state.nexusPulseWaves.push({ radius: 0, maxRadius: Math.min(width, height) * 0.5, speed: 0.9, opacity: 0.35 }); state.nexusLastRippleTime = state.time; state.nexusFirstRipple = false; }
state.nexusOrbits.forEach((o) => { o.rotation += o.speed; });
state.nexusPulseWaves.forEach((w: any) => { w.radius += w.speed; w.opacity = 1 - w.radius / w.maxRadius; state.nexusNodes.forEach((n: any) => { if (Math.abs(w.radius - state.nexusOrbits[n.orbitIndex].radius) < 12) { n.energy = 0.5; n.brightness = 0.85; } }); });
state.nexusPulseWaves = state.nexusPulseWaves.filter((w: any) => w.opacity > 0);
state.nexusOrbits.forEach((o) => { ctx.beginPath(); ctx.arc(centerX, centerY, o.radius, 0, Math.PI * 2); ctx.strokeStyle = o.color; ctx.globalAlpha = 0.15 * alpha; ctx.lineWidth = 1; ctx.stroke(); });
state.nexusPulseWaves.forEach((w: any) => { ctx.beginPath(); ctx.arc(centerX, centerY, w.radius, 0, Math.PI * 2); ctx.strokeStyle = theme.accent; ctx.globalAlpha = w.opacity * 0.35 * alpha; ctx.lineWidth = 1; ctx.stroke(); });
state.nexusNodes.filter((n: any) => n.orbitIndex === 0).forEach((n: any) => { const a = n.angle + state.nexusOrbits[0].rotation, x = centerX + Math.cos(a) * state.nexusOrbits[0].radius, y = centerY + Math.sin(a) * state.nexusOrbits[0].radius; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.lineTo(x, y); const gr = ctx.createLinearGradient(centerX, centerY, x, y); gr.addColorStop(0, theme.accent); gr.addColorStop(1, n.color); ctx.strokeStyle = gr; ctx.globalAlpha = (0.15 + n.energy * 0.4) * alpha; ctx.lineWidth = 1; ctx.stroke(); });
state.nexusNodes.forEach((n: any) => { const o = state.nexusOrbits[n.orbitIndex], a = n.angle + o.rotation, x = centerX + Math.cos(a) * o.radius, y = centerY + Math.sin(a) * o.radius; n.energy *= 0.95; n.brightness = Math.max(0.6, n.brightness * 0.98); const ns = n.size * (1 + n.energy * 0.5), gs = ns * 2 * (1 + n.energy); const gr = ctx.createRadialGradient(x, y, 0, x, y, gs); gr.addColorStop(0, n.energy > 0.1 ? theme.centerDot : n.color); gr.addColorStop(0.3, n.color); gr.addColorStop(1, "transparent"); ctx.beginPath(); ctx.arc(x, y, gs, 0, Math.PI * 2); ctx.fillStyle = gr; ctx.globalAlpha = (0.3 + n.energy * 0.5) * alpha; ctx.fill(); ctx.beginPath(); ctx.arc(x, y, ns, 0, Math.PI * 2); ctx.fillStyle = n.energy > 0.1 ? theme.centerDot : n.color; ctx.globalAlpha = n.brightness * alpha; ctx.fill(); });
const pa = Math.sin(state.time * 0.003 + state.core.pulsePhase) * 0.3 + 0.7, cs = state.core.baseSize * pa; const og = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, state.core.glowSize * pa); og.addColorStop(0, theme.accent); og.addColorStop(0.3, theme.accent); og.addColorStop(1, "transparent"); ctx.beginPath(); ctx.arc(centerX, centerY, state.core.glowSize * pa, 0, Math.PI * 2); ctx.fillStyle = og; ctx.globalAlpha = 0.2 * pa * alpha; ctx.fill(); ctx.beginPath(); ctx.arc(centerX, centerY, cs, 0, Math.PI * 2); ctx.fillStyle = theme.centerDot; ctx.globalAlpha = 0.95 * alpha; ctx.fill();
}
function drawRelay(alpha: number) {
const af = state.relayConnections.filter((c: any) => c.active).length;
if (af < 2 && state.targetMode === "invoke") { const ia = state.relayConnections.filter((c: any) => !c.active); if (ia.length > 0) { const c = ia[Math.floor(Math.random() * ia.length)]; c.active = true; c.flowProgress = 0; state.relayNodes[c.from].energy = 0.8; } }
state.relayConnections.forEach((c: any) => { const from = state.relayNodes[c.from], to = state.relayNodes[c.to]; ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.strokeStyle = from.color; ctx.globalAlpha = (c.active ? 0.4 : c.strength * 0.15) * alpha; ctx.lineWidth = c.active ? 1.5 : 1; ctx.stroke(); if (c.active) { c.flowProgress += 0.008; const fx = from.x + (to.x - from.x) * c.flowProgress, fy = from.y + (to.y - from.y) * c.flowProgress; const tg = ctx.createLinearGradient(from.x, from.y, fx, fy); tg.addColorStop(0, "transparent"); tg.addColorStop(1, theme.purple); ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(fx, fy); ctx.strokeStyle = tg; ctx.globalAlpha = 0.6 * alpha; ctx.lineWidth = 2; ctx.stroke(); ctx.beginPath(); ctx.arc(fx, fy, 3, 0, Math.PI * 2); ctx.fillStyle = theme.centerDot; ctx.globalAlpha = 0.9 * alpha; ctx.fill(); if (c.flowProgress >= 1) { c.active = false; state.relayNodes[c.to].energy = 0.8; } } });
state.relayNodes.forEach((n: any) => { n.energy *= 0.94; const ns = n.size * (1 + n.energy * 1.2), gs = ns * 2.5 * (1 + n.energy * 1.2); const gr = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, gs); gr.addColorStop(0, n.energy > 0.1 ? theme.centerDot : n.color); gr.addColorStop(0.2, n.energy > 0.1 ? theme.purple : n.color); gr.addColorStop(1, "transparent"); ctx.beginPath(); ctx.arc(n.x, n.y, gs, 0, Math.PI * 2); ctx.fillStyle = gr; ctx.globalAlpha = (0.4 + n.energy * 0.5) * alpha; ctx.fill(); ctx.beginPath(); ctx.arc(n.x, n.y, ns, 0, Math.PI * 2); ctx.fillStyle = n.energy > 0.15 ? theme.centerDot : n.color; ctx.globalAlpha = (0.95 + n.energy * 0.05) * alpha; ctx.fill(); });
}
function easeInOutQuad(t: number) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function drawScales(alpha: number) {
if (state.time % 60 === 0 && state.targetMode === "pay") { const s = state.scalesLeftNodes[Math.floor(Math.random() * state.scalesLeftNodes.length)]; state.scalesTransactions.push({ x: s.x, y: s.y, startX: s.x, startY: s.y, targetX: centerX, targetY: centerY, progress: 0, speed: 0.01, color: theme.accent, fromLeft: true, phase: 1 }); s.energy = 0.8; }
if (state.time % 80 === 0 && state.targetMode === "pay") { const s = state.scalesRightNodes[Math.floor(Math.random() * state.scalesRightNodes.length)]; state.scalesTransactions.push({ x: s.x, y: s.y, startX: s.x, startY: s.y, targetX: centerX, targetY: centerY, progress: 0, speed: 0.01, color: theme.pink, fromLeft: false, phase: 1 }); s.energy = 0.8; }
state.scalesLeftNodes.forEach((n: any) => { ctx.beginPath(); ctx.moveTo(n.x, n.y); ctx.quadraticCurveTo((n.x + centerX) / 2, n.y + (centerY - n.y) * 0.3, centerX, centerY); ctx.strokeStyle = theme.accent; ctx.globalAlpha = (0.12 + n.energy * 0.2) * alpha; ctx.lineWidth = 1; ctx.stroke(); });
state.scalesRightNodes.forEach((n: any) => { ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.quadraticCurveTo((n.x + centerX) / 2, n.y + (centerY - n.y) * 0.3, n.x, n.y); ctx.strokeStyle = theme.pink; ctx.globalAlpha = (0.12 + n.energy * 0.2) * alpha; ctx.lineWidth = 1; ctx.stroke(); });
const bw = Math.min(width, height) * 0.4; const bg = ctx.createLinearGradient(centerX - bw/2, centerY, centerX + bw/2, centerY); bg.addColorStop(0, theme.accent); bg.addColorStop(0.5, theme.purple); bg.addColorStop(1, theme.pink); ctx.beginPath(); ctx.moveTo(centerX - bw/2, centerY); ctx.lineTo(centerX + bw/2, centerY); ctx.strokeStyle = bg; ctx.globalAlpha = (0.2 + state.exchange.energy * 0.25) * alpha; ctx.lineWidth = 2; ctx.stroke();
[...state.scalesLeftNodes, ...state.scalesRightNodes].forEach((n: any) => { n.energy *= 0.94; n.brightness = Math.max(0.65, 0.65 + n.energy * 0.35); const p = Math.sin(state.time * 0.003 + n.phase) * 0.25 + 0.85, ns = n.size * (1 + n.energy * 0.6) * p, gs = ns * 2.5; const gr = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, gs); gr.addColorStop(0, n.energy > 0.15 ? theme.centerDot : n.color); gr.addColorStop(0.25, n.color); gr.addColorStop(1, "transparent"); ctx.beginPath(); ctx.arc(n.x, n.y, gs, 0, Math.PI * 2); ctx.fillStyle = gr; ctx.globalAlpha = (0.3 + n.energy * 0.5) * alpha; ctx.fill(); ctx.beginPath(); ctx.arc(n.x, n.y, ns, 0, Math.PI * 2); ctx.fillStyle = n.energy > 0.15 ? theme.centerDot : n.color; ctx.globalAlpha = n.brightness * alpha; ctx.fill(); });
state.exchange.energy *= 0.92; const p = Math.sin(state.time * 0.003 + state.exchange.pulsePhase) * 0.3 + 0.7, es = state.exchange.baseSize * p * (1 + state.exchange.energy * 0.5); const eg = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, state.exchange.glowSize * p); eg.addColorStop(0, theme.purple); eg.addColorStop(0.3, theme.purple); eg.addColorStop(1, "transparent"); ctx.beginPath(); ctx.arc(centerX, centerY, state.exchange.glowSize * p, 0, Math.PI * 2); ctx.fillStyle = eg; ctx.globalAlpha = (0.2 + state.exchange.energy * 0.35) * alpha; ctx.fill(); ctx.beginPath(); ctx.arc(centerX, centerY, es, 0, Math.PI * 2); ctx.fillStyle = theme.centerDot; ctx.globalAlpha = 0.95 * alpha; ctx.fill();
state.scalesTransactions.forEach((tx: any, idx: number) => { tx.progress += tx.speed; if (tx.phase === 1) { const t = easeInOutQuad(tx.progress); tx.x = tx.startX + (tx.targetX - tx.startX) * t; tx.y = tx.startY + (tx.targetY - tx.startY) * t + Math.sin(tx.progress * Math.PI * 2) * 15; if (tx.progress >= 1) { state.exchange.energy = 1; tx.phase = 2; tx.progress = 0; tx.startX = centerX; tx.startY = centerY; const tgts = tx.fromLeft ? state.scalesRightNodes : state.scalesLeftNodes; const tgt = tgts[Math.floor(Math.random() * tgts.length)]; tx.targetX = tgt.x; tx.targetY = tgt.y; tx.destNode = tgt; } } else if (tx.phase === 2) { const t = easeInOutQuad(tx.progress); tx.x = tx.startX + (tx.targetX - tx.startX) * t; tx.y = tx.startY + (tx.targetY - tx.startY) * t + Math.sin(tx.progress * Math.PI * 2) * 15; if (tx.progress >= 1) { if (tx.destNode) tx.destNode.energy = 0.8; state.scalesTransactions.splice(idx, 1); } } ctx.beginPath(); ctx.arc(tx.x, tx.y, 3, 0, Math.PI * 2); ctx.fillStyle = theme.centerDot; ctx.globalAlpha = 0.95 * alpha; ctx.fill(); });
}
function getTemperatureColor(t: number) { if (t < 0.2) { const x = t / 0.2; return { r: Math.floor(20 + x * 20), g: Math.floor(40 + x * 80), b: Math.floor(140 + x * 40), a: 0.5 + t * 0.4 }; } else if (t < 0.4) { const x = (t - 0.2) / 0.2; return { r: Math.floor(40 - x * 20), g: Math.floor(120 + x * 100), b: Math.floor(180 - x * 30), a: 0.5 + t * 0.4 }; } else if (t < 0.6) { const x = (t - 0.4) / 0.2; return { r: Math.floor(20 + x * 200), g: Math.floor(220 + x * 20), b: Math.floor(150 - x * 80), a: 0.6 + t * 0.3 }; } else if (t < 0.8) { const x = (t - 0.6) / 0.2; return { r: Math.floor(220 + x * 35), g: Math.floor(240 - x * 56), b: Math.floor(70 - x * 40), a: 0.7 + t * 0.25 }; } else { const x = (t - 0.8) / 0.2; return { r: 255, g: Math.floor(184 - x * 60), b: Math.floor(30 + x * 170), a: 0.8 + t * 0.15 }; } }
function getTempColorString(t: number) { const c = getTemperatureColor(t); return `rgba(${c.r}, ${c.g}, ${c.b}, ${c.a})`; }
function drawTrust(alpha: number) {
state.trustNodes.forEach((n: any) => { const hr = 40 + n.currentTrust * 60, c = getTemperatureColor(n.currentTrust); const hg = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, hr); hg.addColorStop(0, `rgba(${c.r}, ${c.g}, ${c.b}, ${0.3 * n.currentTrust})`); hg.addColorStop(0.5, `rgba(${c.r}, ${c.g}, ${c.b}, ${0.15 * n.currentTrust})`); hg.addColorStop(1, "transparent"); ctx.beginPath(); ctx.arc(n.x, n.y, hr, 0, Math.PI * 2); ctx.fillStyle = hg; ctx.globalAlpha = alpha; ctx.fill(); });
state.trustConnections.forEach((c: any) => { const f = state.trustNodes[c.from], t = state.trustNodes[c.to]; const diff = f.currentTrust - t.currentTrust, flow = diff * 0.025 * c.strength; f.currentTrust -= flow * 0.5; t.currentTrust += flow * 0.5; });
state.trustNodes.forEach((n: any) => { n.currentTrust += (n.baseTrust - n.currentTrust) * 0.012; n.currentTrust = Math.max(0, Math.min(1, n.currentTrust)); });
state.trustConnections.forEach((c: any) => { const f = state.trustNodes[c.from], t = state.trustNodes[c.to], avg = (f.currentTrust + t.currentTrust) / 2; const gr = ctx.createLinearGradient(f.x, f.y, t.x, t.y); gr.addColorStop(0, getTempColorString(f.currentTrust)); gr.addColorStop(1, getTempColorString(t.currentTrust)); ctx.beginPath(); ctx.moveTo(f.x, f.y); ctx.lineTo(t.x, t.y); ctx.strokeStyle = gr; ctx.globalAlpha = (0.2 + avg * 0.3) * alpha; ctx.lineWidth = 1 + avg; ctx.stroke(); c.flowProgress += 0.004 * c.flowDirection; if (c.flowProgress > 1) c.flowProgress = 0; if (c.flowProgress < 0) c.flowProgress = 1; const fx = f.x + (t.x - f.x) * c.flowProgress, fy = f.y + (t.y - f.y) * c.flowProgress, fc = getTemperatureColor(avg); ctx.beginPath(); ctx.arc(fx, fy, 2 + avg, 0, Math.PI * 2); ctx.fillStyle = `rgba(${fc.r}, ${fc.g}, ${fc.b}, 1)`; ctx.globalAlpha = 0.8 * alpha; ctx.fill(); });
state.trustNodes.forEach((n: any) => { const p = Math.sin(state.time * 0.003 + n.phase) * 0.3 + 0.7, ns = n.size * (n.isSource ? p : 0.9 + n.currentTrust * 0.2), gs = ns * (2 + n.currentTrust * 2.5), c = getTemperatureColor(n.currentTrust); const gr = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, gs); gr.addColorStop(0, n.currentTrust > 0.7 ? theme.centerDot : `rgba(${c.r}, ${c.g}, ${c.b}, 1)`); gr.addColorStop(0.3, `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`); gr.addColorStop(1, "transparent"); ctx.beginPath(); ctx.arc(n.x, n.y, gs, 0, Math.PI * 2); ctx.fillStyle = gr; ctx.globalAlpha = (0.5 + n.currentTrust * 0.4) * alpha; ctx.fill(); ctx.beginPath(); ctx.arc(n.x, n.y, ns, 0, Math.PI * 2); ctx.fillStyle = n.currentTrust > 0.7 ? theme.centerDot : `rgba(${c.r}, ${c.g}, ${c.b}, 1)`; ctx.globalAlpha = (0.9 + n.currentTrust * 0.1) * alpha; ctx.fill(); if (n.isSource) { ctx.beginPath(); ctx.arc(n.x, n.y, ns + 5 + p * 3, 0, Math.PI * 2); ctx.strokeStyle = `rgba(${c.r}, ${c.g}, ${c.b}, 1)`; ctx.globalAlpha = 0.6 * p * alpha; ctx.lineWidth = 1.5; ctx.stroke(); } });
}
function updateLattice() { const b = Math.sin(state.time * 0.00942) * 0.0735 + 1; state.latticeNodes.forEach((n) => { const dx = n.baseX - centerX, dy = n.baseY - centerY; n.x = centerX + dx * b + Math.sin(state.time * 0.0008 + n.phase) * 2; n.y = centerY + dy * b + Math.cos(state.time * 0.001 + n.phase) * 2; }); }
function updateRelay() { state.relayNodes.forEach((n: any) => { const d = Math.sin(state.time * 0.003 + n.phase) * 10 * (1 + n.layer * 0.5); n.x = n.baseX + d; n.y = n.baseY + Math.cos(state.time * 0.0035 + n.phase) * 10; }); }
function updateScales() { [...state.scalesLeftNodes, ...state.scalesRightNodes].forEach((n: any) => { n.x = n.baseX + Math.sin(state.time * 0.001 + n.phase) * 5; n.y = n.baseY + Math.cos(state.time * 0.0015 + n.phase) * 5; }); }
function updateTrust() { state.trustNodes.forEach((n: any) => { n.x = n.baseX + Math.sin(state.time * 0.001 + n.phase) * 8; n.y = n.baseY + Math.cos(state.time * 0.0012 + n.phase) * 8; }); }
function triggerLatticeDiffuse(node: any) { if (state.time - (node.lastSignalTime || 0) < 48) return; node.lastSignalTime = state.time; node.energy = 0.6; state.latticeDiffuseWaves.push(new LatticeDiffuseWave(node)); }
function checkLatticeMouseHover() { if (state.mouse.x === null || state.mouse.y === null || state.currentMode !== "lattice") return; state.latticeNodes.forEach((n) => { const dx = state.mouse.x! - n.x, dy = state.mouse.y! - n.y; if (Math.sqrt(dx*dx + dy*dy) < 25) triggerLatticeDiffuse(n); }); }
function animate() {
state.time++; ctx.clearRect(0, 0, width, height);
if (state.currentMode !== state.targetMode) { state.transitionProgress -= 0.05; if (state.transitionProgress <= 0) { state.currentMode = state.targetMode; state.transitionProgress = 0; } } else if (state.transitionProgress < 1) { state.transitionProgress = Math.min(1, state.transitionProgress + 0.05); }
state.latticeDiffuseWaves = state.latticeDiffuseWaves.filter((w: any) => !w.update()); checkLatticeMouseHover();
updateLattice(); updateRelay(); updateScales(); updateTrust();
const da = state.transitionProgress;
if (state.currentMode === "lattice") drawLattice(da); else if (state.currentMode === "discover") drawNexus(da); else if (state.currentMode === "invoke") drawRelay(da); else if (state.currentMode === "pay") drawScales(da); else if (state.currentMode === "trust") drawTrust(da);
if (state.transitionProgress < 1 && state.currentMode !== state.targetMode) { const pa = 1 - state.transitionProgress; if (state.currentMode === "lattice") drawLattice(pa); }
animationRef.current = requestAnimationFrame(animate);
}
initLattice(); initNexus(); initRelay(); initScales(); initTrust();
const handleMouseMove = (e: MouseEvent) => { const r = canvas.getBoundingClientRect(); state.mouse.x = e.clientX - r.left; state.mouse.y = e.clientY - r.top; };
const handleMouseLeave = () => { state.mouse.x = null; state.mouse.y = null; };
canvas.addEventListener("mousemove", handleMouseMove); canvas.addEventListener("mouseleave", handleMouseLeave);
animate();
return () => { if (animationRef.current) cancelAnimationFrame(animationRef.current); canvas.removeEventListener("mousemove", handleMouseMove); canvas.removeEventListener("mouseleave", handleMouseLeave); };
}, [size, theme, colorArray]);
return <div className={`relative ${className}`}><canvas ref={canvasRef} className="block" style={{ width: size, height: size }} /></div>;
}
export { WanderingDots };import WanderingDots from "@/components/WanderingDots";
import { useState } from "react";
// Modes: "lattice" (Together), "discover" (Seek), "invoke" (Relay), "pay" (Echo), "trust" (Influence)
// Dark variant (default) - use on dark backgrounds
export default function DotsDark() {
const [mode, setMode] = useState("lattice"); // Together
return (
<div className="bg-[#0a0a0a] min-h-screen flex items-center justify-center">
<WanderingDots mode={mode} variant="dark" size={400} />
</div>
);
}
// Light variant - use on light backgrounds
export function DotsLight() {
const [mode, setMode] = useState("lattice"); // Together
return (
<div className="bg-white min-h-screen flex items-center justify-center">
<WanderingDots mode={mode} variant="light" size={400} />
</div>
);
}
// With mode switcher (Together, Seek, Relay, Echo, Influence)
export function DotsWithSwitcher() {
const [mode, setMode] = useState("lattice");
const modeLabels = { lattice: "Together", discover: "Seek", invoke: "Relay", pay: "Echo", trust: "Influence" };
return (
<div className="bg-white min-h-screen flex items-center justify-center">
<div className="text-center">
<WanderingDots mode={mode} variant="light" size={400} />
<div className="flex gap-2 mt-4 justify-center">
{Object.entries(modeLabels).map(([key, label]) => (
<button
key={key}
onClick={() => setMode(key)}
className={`px-3 py-1 rounded text-sm ${mode === key ? "bg-gray-900 text-white" : "bg-gray-100 text-gray-700"}`}
>
{label}
</button>
))}
</div>
</div>
</div>
);
}