diff --git a/public/backgroud_effect.webp b/public/backgroud_effect.webp new file mode 100644 index 0000000..bfae3d4 Binary files /dev/null and b/public/backgroud_effect.webp differ diff --git a/public/project pics/emailsorter.webp b/public/project pics/emailsorter.webp new file mode 100644 index 0000000..0fe0adf Binary files /dev/null and b/public/project pics/emailsorter.webp differ diff --git a/src/components/BorderGlow.tsx b/src/components/BorderGlow.tsx index b34e1e2..aa90a62 100644 --- a/src/components/BorderGlow.tsx +++ b/src/components/BorderGlow.tsx @@ -127,9 +127,18 @@ const BorderGlow = ({ const angleStart = 110; const angleEnd = 465; let cancelled = false; + let running = false; + let visible = typeof IntersectionObserver === 'undefined'; function runSweep() { if (cancelled || !card) return; + // Nur sweepen, solange die Karte im Bild ist – sonst malt der Browser + // Maske, Verläufe und Schatten in jedem Frame umsonst neu + if (!visible) { + running = false; + return; + } + running = true; card.classList.add('sweep-active'); card.style.setProperty('--cursor-angle', `${angleStart}deg`); animateValue({ duration: 500, onUpdate: v => card.style.setProperty('--edge-proximity', String(v)) }); @@ -151,8 +160,19 @@ const BorderGlow = ({ }); } - runSweep(); - return () => { cancelled = true; }; + if (visible) { + runSweep(); + return () => { cancelled = true; }; + } + const observer = new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting; + if (visible && !running) runSweep(); + }); + observer.observe(card); + return () => { + cancelled = true; + observer.disconnect(); + }; }, [animated]); const glowVars = buildGlowVars(glowColor, glowIntensity); diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 7ade1e5..a264f85 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -1,13 +1,15 @@ import { useNavigate } from "react-router-dom"; import { ArrowRight } from "lucide-react"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect, useRef, lazy, Suspense } from "react"; import { useTheme } from "next-themes"; import { useQuery } from "@tanstack/react-query"; -import Silk from "@/components/Silk"; import CountUp from "@/components/CountUp"; import { trackCtaClick } from "@/lib/analytics"; import { fetchProjects } from "@/lib/projects"; +// WebGL-Effekte als eigene Chunks laden, damit der Text nicht auf three.js/ogl warten muss +const Silk = lazy(() => import("@/components/Silk")); + const SPARKLE_SVG = ( {
{/* Silk animated background */}
- + + {/* sanft einblenden, weil der Hintergrund jetzt kurz nach dem Text kommt */} +
+ +
+
diff --git a/src/components/LightRays.tsx b/src/components/LightRays.tsx index 78f8573..cd68f0b 100644 --- a/src/components/LightRays.tsx +++ b/src/components/LightRays.tsx @@ -127,12 +127,17 @@ export default function LightRays({ cleanupFunctionRef.current = null; } + // Wird der Effekt während der Wartezeit schon wieder aufgeräumt (schnell + // vorbeigescrollt, Seitenwechsel), darf danach keine Render-Schleife mehr + // starten – sonst läuft sie unsichtbar weiter, ohne dass sie jemand beendet. + let cancelled = false; + const initializeWebGL = async () => { if (!containerRef.current) return; await new Promise((resolve) => setTimeout(resolve, 10)); - if (!containerRef.current) return; + if (cancelled || !containerRef.current) return; const isMobile = typeof window !== "undefined" && @@ -390,6 +395,7 @@ void main() { initializeWebGL(); return () => { + cancelled = true; if (cleanupFunctionRef.current) { cleanupFunctionRef.current(); cleanupFunctionRef.current = null; diff --git a/src/components/PixelBlast.tsx b/src/components/PixelBlast.tsx index b386a9c..5852c09 100644 --- a/src/components/PixelBlast.tsx +++ b/src/components/PixelBlast.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/no-unknown-property */ import { Effect, EffectComposer, EffectPass, RenderPass } from 'postprocessing'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import * as THREE from 'three'; import './PixelBlast.css'; @@ -255,13 +255,35 @@ interface PixelBlastProps { noiseAmount?: number; } +type PixelBlastInstance = { + loop: { raf: number; stopped: boolean }; + resizeObserver?: ResizeObserver; + quad?: THREE.Mesh; + material: THREE.ShaderMaterial; + composer?: EffectComposer; + renderer: THREE.WebGLRenderer; +}; + +// Räumt eine PixelBlast-Instanz vollständig ab (Render-Schleife, GPU-Ressourcen, Canvas) +const disposeThree = (t: PixelBlastInstance) => { + t.loop.stopped = true; + cancelAnimationFrame(t.loop.raf); + t.resizeObserver?.disconnect(); + t.quad?.geometry.dispose(); + t.material.dispose(); + t.composer?.dispose(); + t.renderer.dispose(); + t.renderer.forceContextLoss(); + t.renderer.domElement.remove(); +}; + const PixelBlast = ({ variant = 'square', pixelSize = 3, color = '#B497CF', className, style, - antialias = true, + antialias = false, patternScale = 2, patternDensity = 1, liquid = false, @@ -280,14 +302,57 @@ const PixelBlast = ({ noiseAmount = 0, }: PixelBlastProps) => { const containerRef = useRef(null); - const visibilityRef = useRef({ visible: true }); + const visibilityRef = useRef({ visible: !autoPauseOffscreen }); const speedRef = useRef(speed); const threeRef = useRef(null); const prevConfigRef = useRef(null); + const [shouldInit, setShouldInit] = useState(!autoPauseOffscreen); + + // WebGL erst anlegen, wenn der Abschnitt höchstens eine Bildschirmhöhe + // entfernt ist, und nur rendern, solange er sichtbar ist. + useEffect(() => { + const container = containerRef.current; + if (!container || !autoPauseOffscreen) return; + if (typeof IntersectionObserver === 'undefined') { + visibilityRef.current.visible = true; + setShouldInit(true); + return; + } + const initObserver = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return; + setShouldInit(true); + initObserver.disconnect(); + }, + { rootMargin: '100% 0px' } + ); + const visibilityObserver = new IntersectionObserver(([entry]) => { + visibilityRef.current.visible = entry.isIntersecting; + if (entry.isIntersecting) threeRef.current?.resume(); + }); + initObserver.observe(container); + visibilityObserver.observe(container); + return () => { + initObserver.disconnect(); + visibilityObserver.disconnect(); + }; + }, [autoPauseOffscreen]); + + // Beim Unmount (Seitenwechsel, Theme-Wechsel) alles abräumen – vorher liefen + // die Render-Schleifen im Hintergrund weiter. + useEffect( + () => () => { + if (threeRef.current) { + disposeThree(threeRef.current); + threeRef.current = null; + } + }, + [] + ); useEffect(() => { const container = containerRef.current; - if (!container) return; + if (!container || !shouldInit) return; speedRef.current = speed; const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount']; @@ -301,21 +366,12 @@ const PixelBlast = ({ if (mustReinit) { if (threeRef.current) { - const t = threeRef.current; - t.resizeObserver?.disconnect(); - cancelAnimationFrame(t.raf); - t.quad?.geometry.dispose(); - t.material.dispose(); - t.composer?.dispose(); - t.renderer.dispose(); - t.renderer.forceContextLoss(); - if (t.renderer.domElement.parentElement === container) - container.removeChild(t.renderer.domElement); + disposeThree(threeRef.current); threeRef.current = null; } const canvas = document.createElement('canvas'); - const renderer = new THREE.WebGLRenderer({ canvas, antialias, alpha: true, powerPreference: 'high-performance' }); + const renderer = new THREE.WebGLRenderer({ canvas, antialias, alpha: true }); renderer.domElement.style.width = '100%'; renderer.domElement.style.height = '100%'; renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); @@ -446,12 +502,8 @@ const PixelBlast = ({ renderer.domElement.addEventListener('pointerdown', onPointerDown, { passive: true }); renderer.domElement.addEventListener('pointermove', onPointerMove, { passive: true }); - let raf = 0; - const animate = () => { - if (autoPauseOffscreen && !visibilityRef.current.visible) { - raf = requestAnimationFrame(animate); - return; - } + const loop = { raf: 0, stopped: false }; + const renderFrame = () => { uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current; if (liquidEffect) liquidEffect.uniforms.get('uTime')!.value = uniforms.uTime.value; if (composer) { @@ -462,11 +514,30 @@ const PixelBlast = ({ }); composer.render(); } else renderer.render(scene, camera); - raf = requestAnimationFrame(animate); }; - raf = requestAnimationFrame(animate); + const animate = () => { + loop.raf = 0; + if (loop.stopped) return; + // Unsichtbar: Schleife anhalten, der IntersectionObserver startet sie wieder + if (autoPauseOffscreen && !visibilityRef.current.visible) return; + renderFrame(); + loop.raf = requestAnimationFrame(animate); + }; + const resume = () => { + if (!loop.stopped && !loop.raf) loop.raf = requestAnimationFrame(animate); + }; + // Shader im Hintergrund kompilieren (KHR_parallel_shader_compile), statt den + // Main-Thread beim ersten Frame zu blockieren; danach erst rendern + renderer + .compileAsync(scene, camera) + .catch(() => undefined) + .then(() => { + if (loop.stopped) return; + renderFrame(); + resume(); + }); - threeRef.current = { renderer, scene, camera, material, clock, clickIx: 0, uniforms, resizeObserver: ro, raf, quad, timeOffset, composer, touch, liquidEffect }; + threeRef.current = { renderer, scene, camera, material, clock, clickIx: 0, uniforms, resizeObserver: ro, loop, resume, quad, timeOffset, composer, touch, liquidEffect }; } else { const t = threeRef.current; t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0; @@ -490,26 +561,11 @@ const PixelBlast = ({ } prevConfigRef.current = cfg; - return () => { - if (threeRef.current && mustReinit) return; - if (!threeRef.current) return; - const t = threeRef.current; - t.resizeObserver?.disconnect(); - cancelAnimationFrame(t.raf); - t.quad?.geometry.dispose(); - t.material.dispose(); - t.composer?.dispose(); - t.renderer.dispose(); - t.renderer.forceContextLoss(); - if (t.renderer.domElement.parentElement === container) - container.removeChild(t.renderer.domElement); - threeRef.current = null; - }; }, [ antialias, liquid, noiseAmount, pixelSize, patternScale, patternDensity, enableRipples, rippleIntensityScale, rippleThickness, rippleSpeed, pixelSizeJitter, edgeFade, transparent, liquidStrength, liquidRadius, - liquidWobbleSpeed, autoPauseOffscreen, variant, color, speed, + liquidWobbleSpeed, autoPauseOffscreen, variant, color, speed, shouldInit, ]); return ( diff --git a/src/components/ProblemSection.tsx b/src/components/ProblemSection.tsx index 9cd4aa5..cbde41e 100644 --- a/src/components/ProblemSection.tsx +++ b/src/components/ProblemSection.tsx @@ -1,9 +1,11 @@ import { Calendar, MessageSquareOff, TrendingDown, Folders } from "lucide-react"; -import { useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { useTheme } from "next-themes"; import { LampTop } from "@/components/ui/lamp"; -import LightRays from "@/components/LightRays"; -import PixelBlast from "@/components/PixelBlast"; + +// WebGL-Effekte als eigene Chunks laden, damit der Text nicht auf three.js/ogl warten muss +const PixelBlast = lazy(() => import("@/components/PixelBlast")); +const LightRays = lazy(() => import("@/components/LightRays")); const ProblemSection = () => { const { resolvedTheme } = useTheme(); @@ -40,18 +42,20 @@ const ProblemSection = () => { {/* PixelBlast animated background - nur im Light Mode */} {isLight && (
- + + +
)} {/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite */} @@ -65,20 +69,22 @@ const ProblemSection = () => { {/* LightRays - nur im Dark Mode */} {isDark && (
- + + +
)} diff --git a/src/components/Process.tsx b/src/components/Process.tsx index 2638af4..ec97aca 100644 --- a/src/components/Process.tsx +++ b/src/components/Process.tsx @@ -1,6 +1,9 @@ +import { lazy, Suspense } from "react"; import CountUp from "@/components/CountUp"; import { TextHoverEffect } from "@/components/ui/text-hover-effect"; -import PixelBlast from "@/components/PixelBlast"; + +// WebGL-Effekte als eigene Chunks laden, damit der Text nicht auf three.js/ogl warten muss +const PixelBlast = lazy(() => import("@/components/PixelBlast")); const Process = () => { const steps = [ @@ -30,25 +33,27 @@ const Process = () => {
{/* PixelBlast animated background */}
- + + +
{/* TextHoverEffect */}
diff --git a/src/components/ProjectShowcase.tsx b/src/components/ProjectShowcase.tsx index bac783c..d94fe62 100644 --- a/src/components/ProjectShowcase.tsx +++ b/src/components/ProjectShowcase.tsx @@ -14,7 +14,7 @@ const projects: Project[] = [ { title: "Email Sorter", description: "Automatisierung · E-Mail-Workflows für Teams", - image: "/project%20pics/emailsorter.png", + image: "/project%20pics/emailsorter.webp", url: "https://emailsorter.webklar.com/", }, { @@ -90,6 +90,8 @@ const ProjectShowcase = () => { {project.title}
diff --git a/src/components/Silk.tsx b/src/components/Silk.tsx index 95b7030..26bb8d5 100644 --- a/src/components/Silk.tsx +++ b/src/components/Silk.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/no-unknown-property */ import { Canvas, useFrame, useThree } from "@react-three/fiber"; -import { forwardRef, useRef, useMemo, useLayoutEffect } from "react"; +import { forwardRef, useRef, useMemo, useLayoutEffect, useEffect, useState } from "react"; import { Color, type Mesh, type ShaderMaterial } from "three"; const hexToNormalizedRGB = (hex: string): [number, number, number] => { @@ -100,7 +100,8 @@ const SilkPlane = forwardRef(function SilkPlane( const mat = ref.current.material as ShaderMaterial & { uniforms: { uTime: { value: number } }; }; - if (mat.uniforms?.uTime) mat.uniforms.uTime.value += 0.1 * delta; + // delta begrenzen, damit das Muster nach einer Pause nicht springt + if (mat.uniforms?.uTime) mat.uniforms.uTime.value += 0.1 * Math.min(delta, 0.1); } }); @@ -135,6 +136,17 @@ const Silk = ({ rotation = 0, }: SilkProps) => { const meshRef = useRef(null); + const wrapperRef = useRef(null); + const [visible, setVisible] = useState(true); + + // Nur rendern, solange der Hintergrund im Bild ist + useEffect(() => { + const el = wrapperRef.current; + if (!el || typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting)); + observer.observe(el); + return () => observer.disconnect(); + }, []); const uniforms = useMemo( () => ({ @@ -150,14 +162,16 @@ const Silk = ({ ); return ( - - - +
+ + + +
); }; diff --git a/src/components/SolutionSection.tsx b/src/components/SolutionSection.tsx index 8e86c56..ae454b9 100644 --- a/src/components/SolutionSection.tsx +++ b/src/components/SolutionSection.tsx @@ -1,11 +1,13 @@ import { Link } from "react-router-dom"; -import { useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { useTheme } from "next-themes"; import { Button } from "@/components/ui/button"; import { ArrowRight, CheckCircle2 } from "lucide-react"; import { LampTop } from "@/components/ui/lamp"; -import LightRays from "@/components/LightRays"; -import PixelBlast from "@/components/PixelBlast"; + +// WebGL-Effekte als eigene Chunks laden, damit der Text nicht auf three.js/ogl warten muss +const PixelBlast = lazy(() => import("@/components/PixelBlast")); +const LightRays = lazy(() => import("@/components/LightRays")); const SolutionSection = () => { const { resolvedTheme } = useTheme(); @@ -29,18 +31,20 @@ const SolutionSection = () => { {/* PixelBlast animated background - nur im Light Mode */} {isLight && (
- + + +
)} {/* Hintergrundbild mittig, auf Handy maximale Breite */} @@ -54,20 +58,22 @@ const SolutionSection = () => { {/* LightRays - nur im Dark Mode */} {isDark && (
- + + +
)} diff --git a/src/components/Values.tsx b/src/components/Values.tsx index 999c521..b83e404 100644 --- a/src/components/Values.tsx +++ b/src/components/Values.tsx @@ -46,7 +46,7 @@ const Values = () => {
diff --git a/src/components/ui/flickering-grid.tsx b/src/components/ui/flickering-grid.tsx index c42e86d..9bd7ab5 100644 --- a/src/components/ui/flickering-grid.tsx +++ b/src/components/ui/flickering-grid.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; // Parse a CSS color string ("#RRGGBB", "#RGB", "rgb(r,g,b)") into [r, g, b]. @@ -49,6 +49,15 @@ interface FlickeringGridProps extends React.HTMLAttributes { fontWeight?: number | string; } +type Grid = { + cols: number; + rows: number; + dpr: number; + squares: Float32Array; + /** 1 = Quadrat liegt im Schriftzug; wird beim ersten Zeichnen berechnet */ + textMask: Uint8Array | null; +}; + export const FlickeringGrid: React.FC = ({ squareSize = 3, gridGap = 3, @@ -65,101 +74,10 @@ export const FlickeringGrid: React.FC = ({ }) => { const canvasRef = useRef(null); const containerRef = useRef(null); - const [isInView, setIsInView] = useState(false); const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const rgb = useMemo(() => parseColor(color), [color]); - const drawGrid = useCallback( - ( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - cols: number, - rows: number, - squares: Float32Array, - dpr: number, - ) => { - ctx.clearRect(0, 0, width, height); - - const maskCanvas = document.createElement("canvas"); - maskCanvas.width = width; - maskCanvas.height = height; - const maskCtx = maskCanvas.getContext("2d", { willReadFrequently: true }); - if (!maskCtx) return; - - if (text) { - maskCtx.save(); - maskCtx.scale(dpr, dpr); - maskCtx.fillStyle = "white"; - maskCtx.font = `${fontWeight} ${fontSize}px "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`; - maskCtx.textAlign = "center"; - maskCtx.textBaseline = "middle"; - maskCtx.fillText(text, width / (2 * dpr), height / (2 * dpr)); - maskCtx.restore(); - } - - for (let i = 0; i < cols; i++) { - for (let j = 0; j < rows; j++) { - const x = i * (squareSize + gridGap) * dpr; - const y = j * (squareSize + gridGap) * dpr; - const squareWidth = squareSize * dpr; - const squareHeight = squareSize * dpr; - - const maskData = maskCtx.getImageData( - x, - y, - squareWidth, - squareHeight, - ).data; - const hasText = maskData.some( - (value, index) => index % 4 === 0 && value > 0, - ); - - const opacity = squares[i * rows + j]; - const finalOpacity = hasText - ? Math.min(1, opacity * 3 + 0.4) - : opacity; - - ctx.fillStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${finalOpacity})`; - ctx.fillRect(x, y, squareWidth, squareHeight); - } - } - }, - [rgb, squareSize, gridGap, text, fontSize, fontWeight], - ); - - const setupCanvas = useCallback( - (canvas: HTMLCanvasElement, width: number, height: number) => { - const dpr = window.devicePixelRatio || 1; - canvas.width = width * dpr; - canvas.height = height * dpr; - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - const cols = Math.ceil(width / (squareSize + gridGap)); - const rows = Math.ceil(height / (squareSize + gridGap)); - - const squares = new Float32Array(cols * rows); - for (let i = 0; i < squares.length; i++) { - squares[i] = Math.random() * maxOpacity; - } - - return { cols, rows, squares, dpr }; - }, - [squareSize, gridGap, maxOpacity], - ); - - const updateSquares = useCallback( - (squares: Float32Array, deltaTime: number) => { - for (let i = 0; i < squares.length; i++) { - if (Math.random() < flickerChance * deltaTime) { - squares[i] = Math.random() * maxOpacity; - } - } - }, - [flickerChance, maxOpacity], - ); - useEffect(() => { const canvas = canvasRef.current; const container = containerRef.current; @@ -168,38 +86,151 @@ export const FlickeringGrid: React.FC = ({ const ctx = canvas.getContext("2d"); if (!ctx) return; - let animationFrameId: number; - let gridParams: ReturnType; + const cell = squareSize + gridGap; + let grid: Grid | null = null; + let needsFullDraw = true; + let animationFrameId = 0; + let lastTime = 0; + let disposed = false; + + const setupCanvas = (w: number, h: number): Grid => { + const dpr = window.devicePixelRatio || 1; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + const cols = Math.ceil(w / cell); + const rows = Math.ceil(h / cell); + + const squares = new Float32Array(cols * rows); + for (let i = 0; i < squares.length; i++) { + squares[i] = Math.random() * maxOpacity; + } + + return { cols, rows, dpr, squares, textMask: null }; + }; + + // Welche Quadrate liegen im Schriftzug? Früher wurde das in jedem Frame + // mit einem getImageData-Aufruf pro Quadrat neu ermittelt (~14.500 Aufrufe, + // 60–80 ms pro Frame) – jetzt einmal pro Größe mit einem einzigen Aufruf. + const buildTextMask = (g: Grid): Uint8Array => { + const mask = new Uint8Array(g.cols * g.rows); + if (!text) return mask; + + const w = canvas.width; + const h = canvas.height; + const maskCanvas = document.createElement("canvas"); + maskCanvas.width = w; + maskCanvas.height = h; + const maskCtx = maskCanvas.getContext("2d", { willReadFrequently: true }); + if (!maskCtx) return mask; + + maskCtx.scale(g.dpr, g.dpr); + maskCtx.fillStyle = "white"; + maskCtx.font = `${fontWeight} ${fontSize}px "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`; + maskCtx.textAlign = "center"; + maskCtx.textBaseline = "middle"; + maskCtx.fillText(text, w / (2 * g.dpr), h / (2 * g.dpr)); + + const data = maskCtx.getImageData(0, 0, w, h).data; + const size = Math.trunc(squareSize * g.dpr); + for (let i = 0; i < g.cols; i++) { + const x0 = Math.trunc(i * cell * g.dpr); + const x1 = Math.min(x0 + size, w); + for (let j = 0; j < g.rows; j++) { + const y0 = Math.trunc(j * cell * g.dpr); + const y1 = Math.min(y0 + size, h); + let hasText = 0; + for (let y = y0; y < y1 && !hasText; y++) { + for (let x = x0; x < x1; x++) { + if (data[(y * w + x) * 4] > 0) { + hasText = 1; + break; + } + } + } + mask[i * g.rows + j] = hasText; + } + } + return mask; + }; + + const paintSquare = (g: Grid, i: number, j: number, clear: boolean) => { + const x = i * cell * g.dpr; + const y = j * cell * g.dpr; + const size = squareSize * g.dpr; + if (clear) { + // alle Pixel löschen, die das Quadrat auch nur teilweise berührt; dank + // gridGap reicht das nie bis zum Nachbarquadrat + const x0 = Math.floor(x); + const y0 = Math.floor(y); + ctx.clearRect(x0, y0, Math.ceil(x + size) - x0, Math.ceil(y + size) - y0); + } + const opacity = g.squares[i * g.rows + j]; + ctx.globalAlpha = g.textMask?.[i * g.rows + j] + ? Math.min(1, opacity * 3 + 0.4) + : opacity; + ctx.fillRect(x, y, size, size); + }; + + const drawAll = (g: Grid) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; + for (let i = 0; i < g.cols; i++) { + for (let j = 0; j < g.rows; j++) { + paintSquare(g, i, j, false); + } + } + }; + + // Nur die Quadrate neu zeichnen, die in diesem Frame flackern + const flicker = (g: Grid, deltaTime: number) => { + const chance = flickerChance * deltaTime; + for (let i = 0; i < g.cols; i++) { + for (let j = 0; j < g.rows; j++) { + if (Math.random() < chance) { + g.squares[i * g.rows + j] = Math.random() * maxOpacity; + paintSquare(g, i, j, true); + } + } + } + }; + + const animate = (time: number) => { + if (grid) { + if (needsFullDraw) { + if (!grid.textMask) grid.textMask = buildTextMask(grid); + drawAll(grid); + needsFullDraw = false; + } else if (lastTime) { + flicker(grid, (time - lastTime) / 1000); + } + } + lastTime = time; + animationFrameId = requestAnimationFrame(animate); + }; + + const start = () => { + if (animationFrameId) return; + lastTime = 0; + animationFrameId = requestAnimationFrame(animate); + }; + + const stop = () => { + cancelAnimationFrame(animationFrameId); + animationFrameId = 0; + }; const updateCanvasSize = () => { const newWidth = width || container.clientWidth; const newHeight = height || container.clientHeight; setCanvasSize({ width: newWidth, height: newHeight }); - gridParams = setupCanvas(canvas, newWidth, newHeight); + grid = setupCanvas(newWidth, newHeight); + needsFullDraw = true; }; updateCanvasSize(); - let lastTime = 0; - const animate = (time: number) => { - if (!isInView) return; - - const deltaTime = (time - lastTime) / 1000; - lastTime = time; - - updateSquares(gridParams.squares, deltaTime); - drawGrid( - ctx, - canvas.width, - canvas.height, - gridParams.cols, - gridParams.rows, - gridParams.squares, - gridParams.dpr, - ); - animationFrameId = requestAnimationFrame(animate); - }; - const resizeObserver = new ResizeObserver(() => { updateCanvasSize(); }); @@ -208,23 +239,28 @@ export const FlickeringGrid: React.FC = ({ const intersectionObserver = new IntersectionObserver( ([entry]) => { - setIsInView(entry.isIntersecting); + if (entry.isIntersecting) start(); + else stop(); }, { threshold: 0 }, ); intersectionObserver.observe(canvas); - if (isInView) { - animationFrameId = requestAnimationFrame(animate); - } + // Lädt eine Schrift aus dem Font-Stack erst später, Schriftzug neu vermessen + document.fonts?.ready.then(() => { + if (disposed || !grid) return; + grid.textMask = null; + needsFullDraw = true; + }); return () => { - cancelAnimationFrame(animationFrameId); + disposed = true; + stop(); resizeObserver.disconnect(); intersectionObserver.disconnect(); }; - }, [setupCanvas, updateSquares, drawGrid, width, height, isInView]); + }, [width, height, squareSize, gridGap, maxOpacity, flickerChance, rgb, text, fontSize, fontWeight]); return (