Performance: Animationen pausieren, Footer-Raster entschärft, WebGL nachladen
- Footer-Schriftzug (FlickeringGrid): Textmaske einmal pro Größe statt ~14.500 getImageData-Aufrufe pro Frame, nur flackernde Quadrate neu zeichnen (69–86 ms -> 0,1–0,2 ms pro Frame, Darstellung pixelgleich) - PixelBlast: WebGL erst in Viewport-Nähe anlegen, unsichtbar pausieren (autoPauseOffscreen war nie angeschlossen), Shader asynchron kompilieren, beim Unmount aufräumen (Render-Schleifen liefen nach Seitenwechsel weiter); kein MSAA, keine erzwungene High-Performance-GPU - Silk (Hero): nur rendern, solange sichtbar; DPR max. 1,5 - LightRays: keine verwaiste Render-Schleife mehr bei schnellem Vorbeiscrollen - BorderGlow (Kontaktkarte): Sweep nur, solange die Karte sichtbar ist - Silk, PixelBlast, LightRays als eigene Chunks (React.lazy): Haupt-Bundle 1,6 MB -> 646 KB (gzip 470 -> 207 KB) - backgroud_effect und Emailsorter-Vorschaubild als WebP (974 KB -> 15 KB, 1,2 MB -> 11 KB), Projekt-Vorschaubilder lazy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f74ddfb5ae
commit
5cc8b5f66b
13 files changed
+335
-177
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -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 = ({
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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 = (
|
||||
<svg className="btn-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden>
|
||||
<path
|
||||
@@ -88,6 +90,9 @@ const Hero = () => {
|
||||
<section className="relative min-h-screen flex flex-col justify-center overflow-hidden pt-20">
|
||||
{/* Silk animated background */}
|
||||
<div className="absolute inset-0 z-0 w-full h-full">
|
||||
<Suspense fallback={null}>
|
||||
{/* sanft einblenden, weil der Hintergrund jetzt kurz nach dem Text kommt */}
|
||||
<div className="w-full h-full animate-fade-in">
|
||||
<Silk
|
||||
speed={3}
|
||||
scale={0.5}
|
||||
@@ -97,6 +102,8 @@ const Hero = () => {
|
||||
rotation={0}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-6 relative z-10">
|
||||
<div className="max-w-6xl">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const visibilityRef = useRef({ visible: true });
|
||||
const visibilityRef = useRef({ visible: !autoPauseOffscreen });
|
||||
const speedRef = useRef(speed);
|
||||
const threeRef = useRef<any>(null);
|
||||
const prevConfigRef = useRef<any>(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 (
|
||||
|
||||
@@ -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,6 +42,7 @@ const ProblemSection = () => {
|
||||
{/* PixelBlast animated background - nur im Light Mode */}
|
||||
{isLight && (
|
||||
<div className="absolute inset-0 z-0 w-full h-full">
|
||||
<Suspense fallback={null}>
|
||||
<PixelBlast
|
||||
variant="circle"
|
||||
pixelSize={3}
|
||||
@@ -52,6 +55,7 @@ const ProblemSection = () => {
|
||||
edgeFade={0.5}
|
||||
transparent
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
{/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite */}
|
||||
@@ -65,6 +69,7 @@ const ProblemSection = () => {
|
||||
{/* LightRays - nur im Dark Mode */}
|
||||
{isDark && (
|
||||
<div className="absolute inset-0 w-full overflow-hidden z-0">
|
||||
<Suspense fallback={null}>
|
||||
<LightRays
|
||||
raysOrigin="top-center"
|
||||
raysColor="#ef4444"
|
||||
@@ -79,6 +84,7 @@ const ProblemSection = () => {
|
||||
fadeDistance={2}
|
||||
saturation={2}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
<LampTop />
|
||||
|
||||
@@ -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,6 +33,7 @@ const Process = () => {
|
||||
<section id="process" className="pt-8 pb-24 md:pt-12 md:pb-32 bg-secondary/20 relative">
|
||||
{/* PixelBlast animated background */}
|
||||
<div className="absolute inset-0 z-0 w-full h-full">
|
||||
<Suspense fallback={null}>
|
||||
<PixelBlast
|
||||
variant="circle"
|
||||
pixelSize={3}
|
||||
@@ -49,6 +53,7 @@ const Process = () => {
|
||||
edgeFade={0.5}
|
||||
transparent
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
{/* TextHoverEffect */}
|
||||
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
|
||||
|
||||
@@ -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 = () => {
|
||||
<img
|
||||
src={project.image}
|
||||
alt={project.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+18
-4
@@ -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<Mesh, SilkPlaneProps>(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<Mesh>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={wrapperRef} style={{ width: "100%", height: "100%" }}>
|
||||
<Canvas
|
||||
dpr={[1, 2]}
|
||||
frameloop="always"
|
||||
dpr={[1, 1.5]}
|
||||
frameloop={visible ? "always" : "never"}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
camera={{ position: [0, 0, 5], fov: 75 }}
|
||||
>
|
||||
<SilkPlane ref={meshRef} uniforms={uniforms} />
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,6 +31,7 @@ const SolutionSection = () => {
|
||||
{/* PixelBlast animated background - nur im Light Mode */}
|
||||
{isLight && (
|
||||
<div className="absolute inset-0 z-0 w-full h-full">
|
||||
<Suspense fallback={null}>
|
||||
<PixelBlast
|
||||
variant="circle"
|
||||
pixelSize={3}
|
||||
@@ -41,6 +44,7 @@ const SolutionSection = () => {
|
||||
edgeFade={0.5}
|
||||
transparent
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
{/* Hintergrundbild mittig, auf Handy maximale Breite */}
|
||||
@@ -54,6 +58,7 @@ const SolutionSection = () => {
|
||||
{/* LightRays - nur im Dark Mode */}
|
||||
{isDark && (
|
||||
<div className="absolute inset-0 w-full overflow-hidden z-0">
|
||||
<Suspense fallback={null}>
|
||||
<LightRays
|
||||
raysOrigin="top-center"
|
||||
raysColor="#22d3ee"
|
||||
@@ -68,6 +73,7 @@ const SolutionSection = () => {
|
||||
fadeDistance={2}
|
||||
saturation={2}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
<LampTop lineClassName="bg-cyan-400" />
|
||||
|
||||
@@ -46,7 +46,7 @@ const Values = () => {
|
||||
<div
|
||||
className="values-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: "url(/backgroud_effect.png)",
|
||||
backgroundImage: "url(/backgroud_effect.webp)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
@@ -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<HTMLDivElement> {
|
||||
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<FlickeringGridProps> = ({
|
||||
squareSize = 3,
|
||||
gridGap = 3,
|
||||
@@ -65,101 +74,10 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(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<FlickeringGridProps> = ({
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
let gridParams: ReturnType<typeof setupCanvas>;
|
||||
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<FlickeringGridProps> = ({
|
||||
|
||||
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 (
|
||||
<div
|
||||
|
||||
Reference in new issue
Block a user