Spaces:
Sleeping
Sleeping
| export type ConfettiOrigin = { | |
| x: number; | |
| y: number; | |
| }; | |
| export type AttendanceConfettiMode = "checkIn" | "checkOut"; | |
| type ConfettiOptions = { | |
| origin?: ConfettiOrigin; | |
| colors: string[]; | |
| particleCount: number; | |
| durationMs: number; | |
| }; | |
| let activeCanvas: HTMLCanvasElement | null = null; | |
| let activeAnimationId: number | null = null; | |
| const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); | |
| const createCanvas = () => { | |
| const canvas = document.createElement("canvas"); | |
| canvas.style.position = "fixed"; | |
| canvas.style.inset = "0"; | |
| canvas.style.pointerEvents = "none"; | |
| canvas.style.zIndex = "9999"; | |
| canvas.width = window.innerWidth; | |
| canvas.height = window.innerHeight; | |
| document.body.appendChild(canvas); | |
| return canvas; | |
| }; | |
| const teardownCanvas = () => { | |
| if (activeAnimationId != null) { | |
| cancelAnimationFrame(activeAnimationId); | |
| activeAnimationId = null; | |
| } | |
| if (activeCanvas && activeCanvas.parentElement) { | |
| activeCanvas.parentElement.removeChild(activeCanvas); | |
| } | |
| activeCanvas = null; | |
| }; | |
| export const getConfettiOriginFromElement = (el: HTMLElement | null): ConfettiOrigin | undefined => { | |
| if (!el) return undefined; | |
| const rect = el.getBoundingClientRect(); | |
| if (!rect.width || !rect.height) return undefined; | |
| return { | |
| x: clamp01((rect.left + rect.width / 2) / window.innerWidth), | |
| y: clamp01((rect.top + rect.height / 2) / window.innerHeight), | |
| }; | |
| }; | |
| export const triggerConfetti = (opts: ConfettiOptions) => { | |
| if (typeof window === "undefined" || typeof document === "undefined") return; | |
| teardownCanvas(); | |
| activeCanvas = createCanvas(); | |
| const ctx = activeCanvas.getContext("2d"); | |
| if (!ctx) { | |
| teardownCanvas(); | |
| return; | |
| } | |
| const origin = { | |
| x: clamp01(opts.origin?.x ?? 0.5), | |
| y: clamp01(opts.origin?.y ?? 0.35), | |
| }; | |
| const start = performance.now(); | |
| const duration = Math.max(300, opts.durationMs); | |
| const resize = () => { | |
| if (!activeCanvas) return; | |
| activeCanvas.width = window.innerWidth; | |
| activeCanvas.height = window.innerHeight; | |
| }; | |
| window.addEventListener("resize", resize, { passive: true }); | |
| const w = () => activeCanvas?.width ?? window.innerWidth; | |
| const h = () => activeCanvas?.height ?? window.innerHeight; | |
| const startX = origin.x * w(); | |
| const startY = origin.y * h(); | |
| const colors = opts.colors; | |
| const particles = Array.from({ length: Math.max(10, opts.particleCount) }).map(() => { | |
| const angle = (-Math.PI / 2) + (Math.random() - 0.5) * (Math.PI / 1.6); | |
| const speed = 6 + Math.random() * 10; | |
| const size = 4 + Math.random() * 6; | |
| const spin = (Math.random() - 0.5) * 0.3; | |
| return { | |
| x: startX, | |
| y: startY, | |
| vx: Math.cos(angle) * speed, | |
| vy: Math.sin(angle) * speed, | |
| g: 0.25 + Math.random() * 0.35, | |
| r: Math.random() * Math.PI, | |
| vr: spin, | |
| size, | |
| color: colors[Math.floor(Math.random() * colors.length)], | |
| seed: Math.random(), | |
| }; | |
| }); | |
| const loop = (now: number) => { | |
| const t = now - start; | |
| const p = Math.min(1, t / duration); | |
| if (!activeCanvas) return; | |
| ctx.clearRect(0, 0, w(), h()); | |
| for (const part of particles) { | |
| part.vx *= 0.985; | |
| part.vy = part.vy * 0.985 + part.g; | |
| part.x += part.vx; | |
| part.y += part.vy; | |
| part.r += part.vr; | |
| const alpha = 1 - Math.pow(p, 1.8); | |
| if (alpha <= 0) continue; | |
| const flutter = Math.sin((now / 80) + part.seed * 10) * 0.6; | |
| ctx.save(); | |
| ctx.globalAlpha = alpha; | |
| ctx.translate(part.x, part.y); | |
| ctx.rotate(part.r + flutter); | |
| ctx.fillStyle = part.color; | |
| ctx.fillRect(-part.size / 2, -part.size / 2, part.size, part.size * 0.6); | |
| ctx.restore(); | |
| } | |
| if (p >= 1) { | |
| window.removeEventListener("resize", resize); | |
| teardownCanvas(); | |
| return; | |
| } | |
| activeAnimationId = requestAnimationFrame(loop); | |
| }; | |
| activeAnimationId = requestAnimationFrame(loop); | |
| }; | |
| export const triggerAttendanceConfetti = ( | |
| mode: AttendanceConfettiMode, | |
| origin?: ConfettiOrigin | |
| ) => { | |
| if (mode === "checkIn") { | |
| triggerConfetti({ | |
| origin, | |
| colors: ["#22c55e", "#16a34a", "#3b82f6", "#38bdf8", "#facc15"], | |
| particleCount: 90, | |
| durationMs: 1200, | |
| }); | |
| return; | |
| } | |
| triggerConfetti({ | |
| origin, | |
| colors: ["#f97316", "#fb7185", "#a855f7", "#60a5fa", "#facc15"], | |
| particleCount: 110, | |
| durationMs: 1400, | |
| }); | |
| }; | |