UNPKG

@fxshelf/starfield

Version:

Canvas 2D starfield tunnel for React and Vue — perspective fly-through, glitter flashes, additive trails. Zero animation-lib deps. Copy-as-source friendly.

346 lines (341 loc) • 10.4 kB
"use client"; 'use strict'; var react = require('react'); var jsxRuntime = require('react/jsx-runtime'); // src/react/Starfield.tsx // src/core/types.ts var DEFAULTS = { particleCount: 263, color1: "#ffffff", color2: "#ffffff", color3: "#ffffff", speed: 1, density: 48, starSize: 8, focalDepth: 13, turbulence: 0, brightness: 73, glitterIntensity: 4, trailAmount: 26, reverse: false }; // src/core/createStarfield.ts function parseColor(input) { if (!input) return [255, 255, 255, 1]; const s = input.trim(); if (s.charAt(0) === "#") { let hex = s.slice(1); if (hex.length === 3) { hex = hex.split("").map((c) => c + c).join(""); } const num = parseInt(hex, 16); return [num >> 16 & 255, num >> 8 & 255, num & 255, 1]; } const m = s.match(/rgba?\(([^)]+)\)/i); if (m?.[1]) { const parts = m[1].split(",").map((p) => parseFloat(p.trim())); return [parts[0] || 0, parts[1] || 0, parts[2] || 0, parts[3] == null ? 1 : parts[3]]; } return [255, 255, 255, 1]; } function toRuntime(options) { return { reverse: options.reverse, density: options.density, stepZ: options.speed * 8e-4, focalDepth: options.focalDepth / 100, starScale: options.starSize * 0.15, turbulence: options.turbulence * 0.2, glitter: options.glitterIntensity * 0.1, brightness: Math.min(1, options.brightness / 100), trail: options.trailAmount / 100 }; } function createStarfield(canvas, options = {}) { let config = { ...DEFAULTS, ...options }; const maybeCtx = canvas.getContext("2d"); if (!maybeCtx) { return { setOptions() { }, resize() { }, destroy() { } }; } const ctx = maybeCtx; const container = canvas.parentElement; let w = 0; let h = 0; let dpr = 1; let stars = []; let elapsed = 0; let lastT = performance.now(); let raf = null; let destroyed = false; const colorCache = { color1: "", color2: "", color3: "", parsed1: [255, 255, 255, 1], parsed2: [255, 255, 255, 1], parsed3: [255, 255, 255, 1] }; function getCachedColors() { if (config.color1 !== colorCache.color1) { colorCache.color1 = config.color1; colorCache.parsed1 = parseColor(config.color1); } if (config.color2 !== colorCache.color2) { colorCache.color2 = config.color2; colorCache.parsed2 = parseColor(config.color2); } if (config.color3 !== colorCache.color3) { colorCache.color3 = config.color3; colorCache.parsed3 = parseColor(config.color3); } return colorCache; } function makeStar() { return { x: 0, y: 0, z: 0, px: NaN, py: NaN, seed: 0, vmul: 1, colorIdx: 0, flashUntil: 0, nextFlash: 0 }; } function resetStar(s, initial = false) { const c = toRuntime(config); const angle = Math.random() * Math.PI * 2; const radius = (0.2 + Math.random() * 0.8) * (c.density / 15); s.x = Math.cos(angle) * radius; s.y = Math.sin(angle) * radius; if (c.reverse) { s.z = initial ? c.focalDepth + Math.random() * (1 - c.focalDepth) : c.focalDepth; } else { s.z = initial ? Math.random() : 1; } s.px = NaN; s.py = NaN; s.seed = Math.random() * 1e3; s.vmul = 0.6 + Math.random() * 0.8; s.colorIdx = Math.floor(Math.random() * 3); s.flashUntil = 0; s.nextFlash = elapsed + 1 + Math.random() * 4 * (1 / Math.max(1e-4, c.glitter)); } function syncCount() { const count = Math.max(1, Math.floor(config.particleCount)); if (stars.length === count) return; if (stars.length > count) { stars.length = count; return; } while (stars.length < count) { const s = makeStar(); resetStar(s, true); stars.push(s); } } function resize() { if (destroyed) return; const nextDpr = Math.min(window.devicePixelRatio || 1, 2); const rectW = container?.clientWidth || container?.getBoundingClientRect().width || canvas.clientWidth; const rectH = container?.clientHeight || container?.getBoundingClientRect().height || canvas.clientHeight; const nextW = Math.max(1, Math.floor(rectW) || 600); const nextH = Math.max(1, Math.floor(rectH) || 400); if (w === nextW && h === nextH && dpr === nextDpr) return; w = nextW; h = nextH; dpr = nextDpr; canvas.width = Math.floor(w * dpr); canvas.height = Math.floor(h * dpr); canvas.style.width = w + "px"; canvas.style.height = h + "px"; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); } function drawFrame(deltaSec) { const c = toRuntime(config); syncCount(); const colors = getCachedColors(); const palette = [colors.parsed1, colors.parsed2, colors.parsed3]; const rgbStrs = [ "rgb(" + palette[0][0] + ", " + palette[0][1] + ", " + palette[0][2] + ")", "rgb(" + palette[1][0] + ", " + palette[1][1] + ", " + palette[1][2] + ")", "rgb(" + palette[2][0] + ", " + palette[2][1] + ", " + palette[2][2] + ")" ]; const cx = w / 2; const cy = h / 2; const projScale = Math.min(w, h) * 0.9; const dt = Math.max(1e-3, Math.min(0.1, deltaSec)) * 60; const keep = Math.pow(Math.min(0.98, Math.max(0, c.trail)), dt); const trailAlpha = Math.max(0.02, 1 - keep); ctx.globalAlpha = 1; ctx.globalCompositeOperation = "destination-out"; ctx.fillStyle = "rgba(0, 0, 0, " + trailAlpha + ")"; ctx.fillRect(0, 0, w, h); ctx.globalCompositeOperation = "lighter"; for (let i = 0; i < stars.length; i++) { const s = stars[i]; const vz = c.stepZ * s.vmul * dt; if (c.reverse) { s.z += vz; if (s.z >= 1) { resetStar(s); continue; } } else { s.z -= vz; if (s.z <= c.focalDepth) { resetStar(s); continue; } } let tx = s.x; let ty = s.y; if (c.turbulence > 0) { const tVal = elapsed * 1.2 + s.seed; const amp = c.turbulence * (1 - s.z) * 0.25; tx += Math.sin(tVal + s.seed) * amp; ty += Math.cos(tVal * 1.13 + s.seed * 0.7) * amp; } const persp = c.focalDepth / Math.max(s.z, 1e-4); const sx = cx + tx * persp * projScale; const sy = cy + ty * persp * projScale; if (!c.reverse && (sx < -20 || sx > w + 20 || sy < -20 || sy > h + 20)) { resetStar(s); continue; } let flashMult = 1; if (c.glitter > 0) { if (elapsed >= s.nextFlash && s.flashUntil < elapsed) { s.flashUntil = elapsed + 0.04 + Math.random() * 0.07; s.nextFlash = elapsed + 1 + Math.random() * 4 * (1 / Math.max(1e-4, c.glitter)); } if (elapsed <= s.flashUntil) { flashMult = 1 + 2.5 * c.glitter; } } const sizePersp = Math.min(2.5, c.focalDepth / Math.max(s.z, 1e-4) * 0.6); const baseR = Math.max(0.25, c.starScale * (0.4 + sizePersp)); const maxR = 1 + c.starScale * 2.5; const r = Math.min(baseR * flashMult, maxR); const lifeT = c.reverse ? s.z : 1 - s.z; const fadeIn = c.reverse ? Math.min(1, (s.z - c.focalDepth) / (1 - c.focalDepth) / 0.12) : 1; const a = Math.min(1, c.reverse ? 0.85 - lifeT * 0.6 : lifeT * 0.9 + 0.05) * fadeIn * c.brightness * (flashMult > 1 ? 1 : 0.85); const colStr = rgbStrs[s.colorIdx]; if (!Number.isNaN(s.px) && !Number.isNaN(s.py)) { ctx.globalAlpha = a * 0.5; ctx.strokeStyle = colStr; ctx.lineWidth = Math.max(0.4, r * 0.4); ctx.beginPath(); ctx.moveTo(s.px, s.py); ctx.lineTo(sx, sy); ctx.stroke(); } ctx.globalAlpha = a; ctx.fillStyle = colStr; ctx.fillRect(sx - r, sy - r, r * 2, r * 2); if (flashMult > 1) { const rf = Math.min(r * 1.4, maxR * 1.4); ctx.globalAlpha = a * 0.5; ctx.fillRect(sx - rf, sy - rf, rf * 2, rf * 2); } s.px = sx; s.py = sy; } ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; elapsed += Math.min(0.1, Math.max(0, deltaSec)); } function loop(t) { if (destroyed) return; const deltaSec = (t - lastT) / 1e3; lastT = t; drawFrame(deltaSec); raf = requestAnimationFrame(loop); } syncCount(); resize(); raf = requestAnimationFrame(loop); let resizeObserver = null; if (container && typeof ResizeObserver !== "undefined") { resizeObserver = new ResizeObserver(() => resize()); resizeObserver.observe(container); } return { setOptions(partial) { config = { ...config, ...partial }; syncCount(); }, resize, destroy() { destroyed = true; if (raf != null) cancelAnimationFrame(raf); raf = null; resizeObserver?.disconnect(); resizeObserver = null; stars = []; } }; } function Starfield({ children, className, style, ariaLabel = "Starfield background", ...options }) { const canvasRef = react.useRef(null); const instanceRef = react.useRef(null); const [initialOptions] = react.useState(options); react.useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; instanceRef.current = createStarfield(canvas, initialOptions); return () => { instanceRef.current?.destroy(); instanceRef.current = null; }; }, [initialOptions]); react.useEffect(() => { instanceRef.current?.setOptions(options); }); return /* @__PURE__ */ jsxRuntime.jsxs( "div", { className, style: { position: "relative", overflow: "hidden", ...style }, children: [ /* @__PURE__ */ jsxRuntime.jsx( "canvas", { ref: canvasRef, "aria-label": ariaLabel, style: { position: "absolute", inset: 0, width: "100%", height: "100%", display: "block", pointerEvents: "none" } } ), children != null ? /* @__PURE__ */ jsxRuntime.jsx("div", { style: { position: "relative", zIndex: 1, height: "100%" }, children }) : null ] } ); } exports.DEFAULTS = DEFAULTS; exports.Starfield = Starfield; exports.createStarfield = createStarfield; //# sourceMappingURL=index.cjs.map //# sourceMappingURL=index.cjs.map