Archived
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 angleStart = 110;
|
||||||
const angleEnd = 465;
|
const angleEnd = 465;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let running = false;
|
||||||
|
let visible = typeof IntersectionObserver === 'undefined';
|
||||||
|
|
||||||
function runSweep() {
|
function runSweep() {
|
||||||
if (cancelled || !card) return;
|
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.classList.add('sweep-active');
|
||||||
card.style.setProperty('--cursor-angle', `${angleStart}deg`);
|
card.style.setProperty('--cursor-angle', `${angleStart}deg`);
|
||||||
animateValue({ duration: 500, onUpdate: v => card.style.setProperty('--edge-proximity', String(v)) });
|
animateValue({ duration: 500, onUpdate: v => card.style.setProperty('--edge-proximity', String(v)) });
|
||||||
@@ -151,8 +160,19 @@ const BorderGlow = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
runSweep();
|
runSweep();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
visible = entry.isIntersecting;
|
||||||
|
if (visible && !running) runSweep();
|
||||||
|
});
|
||||||
|
observer.observe(card);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
}, [animated]);
|
}, [animated]);
|
||||||
|
|
||||||
const glowVars = buildGlowVars(glowColor, glowIntensity);
|
const glowVars = buildGlowVars(glowColor, glowIntensity);
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ArrowRight } from "lucide-react";
|
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 { useTheme } from "next-themes";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import Silk from "@/components/Silk";
|
|
||||||
import CountUp from "@/components/CountUp";
|
import CountUp from "@/components/CountUp";
|
||||||
import { trackCtaClick } from "@/lib/analytics";
|
import { trackCtaClick } from "@/lib/analytics";
|
||||||
import { fetchProjects } from "@/lib/projects";
|
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 = (
|
const SPARKLE_SVG = (
|
||||||
<svg className="btn-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden>
|
<svg className="btn-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden>
|
||||||
<path
|
<path
|
||||||
@@ -88,6 +90,9 @@ const Hero = () => {
|
|||||||
<section className="relative min-h-screen flex flex-col justify-center overflow-hidden pt-20">
|
<section className="relative min-h-screen flex flex-col justify-center overflow-hidden pt-20">
|
||||||
{/* Silk animated background */}
|
{/* Silk animated background */}
|
||||||
<div className="absolute inset-0 z-0 w-full h-full">
|
<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
|
<Silk
|
||||||
speed={3}
|
speed={3}
|
||||||
scale={0.5}
|
scale={0.5}
|
||||||
@@ -97,6 +102,8 @@ const Hero = () => {
|
|||||||
rotation={0}
|
rotation={0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="container mx-auto px-6 relative z-10">
|
<div className="container mx-auto px-6 relative z-10">
|
||||||
<div className="max-w-6xl">
|
<div className="max-w-6xl">
|
||||||
|
|||||||
@@ -127,12 +127,17 @@ export default function LightRays({
|
|||||||
cleanupFunctionRef.current = null;
|
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 () => {
|
const initializeWebGL = async () => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
if (!containerRef.current) return;
|
if (cancelled || !containerRef.current) return;
|
||||||
|
|
||||||
const isMobile =
|
const isMobile =
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
@@ -390,6 +395,7 @@ void main() {
|
|||||||
initializeWebGL();
|
initializeWebGL();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
if (cleanupFunctionRef.current) {
|
if (cleanupFunctionRef.current) {
|
||||||
cleanupFunctionRef.current();
|
cleanupFunctionRef.current();
|
||||||
cleanupFunctionRef.current = null;
|
cleanupFunctionRef.current = null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable react/no-unknown-property */
|
/* eslint-disable react/no-unknown-property */
|
||||||
import { Effect, EffectComposer, EffectPass, RenderPass } from 'postprocessing';
|
import { Effect, EffectComposer, EffectPass, RenderPass } from 'postprocessing';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import './PixelBlast.css';
|
import './PixelBlast.css';
|
||||||
|
|
||||||
@@ -255,13 +255,35 @@ interface PixelBlastProps {
|
|||||||
noiseAmount?: number;
|
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 = ({
|
const PixelBlast = ({
|
||||||
variant = 'square',
|
variant = 'square',
|
||||||
pixelSize = 3,
|
pixelSize = 3,
|
||||||
color = '#B497CF',
|
color = '#B497CF',
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
antialias = true,
|
antialias = false,
|
||||||
patternScale = 2,
|
patternScale = 2,
|
||||||
patternDensity = 1,
|
patternDensity = 1,
|
||||||
liquid = false,
|
liquid = false,
|
||||||
@@ -280,14 +302,57 @@ const PixelBlast = ({
|
|||||||
noiseAmount = 0,
|
noiseAmount = 0,
|
||||||
}: PixelBlastProps) => {
|
}: PixelBlastProps) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const visibilityRef = useRef({ visible: true });
|
const visibilityRef = useRef({ visible: !autoPauseOffscreen });
|
||||||
const speedRef = useRef(speed);
|
const speedRef = useRef(speed);
|
||||||
const threeRef = useRef<any>(null);
|
const threeRef = useRef<any>(null);
|
||||||
const prevConfigRef = 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(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container || !shouldInit) return;
|
||||||
speedRef.current = speed;
|
speedRef.current = speed;
|
||||||
|
|
||||||
const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount'];
|
const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount'];
|
||||||
@@ -301,21 +366,12 @@ const PixelBlast = ({
|
|||||||
|
|
||||||
if (mustReinit) {
|
if (mustReinit) {
|
||||||
if (threeRef.current) {
|
if (threeRef.current) {
|
||||||
const t = threeRef.current;
|
disposeThree(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;
|
threeRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
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.width = '100%';
|
||||||
renderer.domElement.style.height = '100%';
|
renderer.domElement.style.height = '100%';
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
@@ -446,12 +502,8 @@ const PixelBlast = ({
|
|||||||
renderer.domElement.addEventListener('pointerdown', onPointerDown, { passive: true });
|
renderer.domElement.addEventListener('pointerdown', onPointerDown, { passive: true });
|
||||||
renderer.domElement.addEventListener('pointermove', onPointerMove, { passive: true });
|
renderer.domElement.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||||
|
|
||||||
let raf = 0;
|
const loop = { raf: 0, stopped: false };
|
||||||
const animate = () => {
|
const renderFrame = () => {
|
||||||
if (autoPauseOffscreen && !visibilityRef.current.visible) {
|
|
||||||
raf = requestAnimationFrame(animate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current;
|
uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current;
|
||||||
if (liquidEffect) liquidEffect.uniforms.get('uTime')!.value = uniforms.uTime.value;
|
if (liquidEffect) liquidEffect.uniforms.get('uTime')!.value = uniforms.uTime.value;
|
||||||
if (composer) {
|
if (composer) {
|
||||||
@@ -462,11 +514,30 @@ const PixelBlast = ({
|
|||||||
});
|
});
|
||||||
composer.render();
|
composer.render();
|
||||||
} else renderer.render(scene, camera);
|
} 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 {
|
} else {
|
||||||
const t = threeRef.current;
|
const t = threeRef.current;
|
||||||
t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0;
|
t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0;
|
||||||
@@ -490,26 +561,11 @@ const PixelBlast = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
prevConfigRef.current = cfg;
|
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,
|
antialias, liquid, noiseAmount, pixelSize, patternScale, patternDensity,
|
||||||
enableRipples, rippleIntensityScale, rippleThickness, rippleSpeed,
|
enableRipples, rippleIntensityScale, rippleThickness, rippleSpeed,
|
||||||
pixelSizeJitter, edgeFade, transparent, liquidStrength, liquidRadius,
|
pixelSizeJitter, edgeFade, transparent, liquidStrength, liquidRadius,
|
||||||
liquidWobbleSpeed, autoPauseOffscreen, variant, color, speed,
|
liquidWobbleSpeed, autoPauseOffscreen, variant, color, speed, shouldInit,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Calendar, MessageSquareOff, TrendingDown, Folders } from "lucide-react";
|
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 { useTheme } from "next-themes";
|
||||||
import { LampTop } from "@/components/ui/lamp";
|
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 ProblemSection = () => {
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
@@ -40,6 +42,7 @@ const ProblemSection = () => {
|
|||||||
{/* PixelBlast animated background - nur im Light Mode */}
|
{/* PixelBlast animated background - nur im Light Mode */}
|
||||||
{isLight && (
|
{isLight && (
|
||||||
<div className="absolute inset-0 z-0 w-full h-full">
|
<div className="absolute inset-0 z-0 w-full h-full">
|
||||||
|
<Suspense fallback={null}>
|
||||||
<PixelBlast
|
<PixelBlast
|
||||||
variant="circle"
|
variant="circle"
|
||||||
pixelSize={3}
|
pixelSize={3}
|
||||||
@@ -52,6 +55,7 @@ const ProblemSection = () => {
|
|||||||
edgeFade={0.5}
|
edgeFade={0.5}
|
||||||
transparent
|
transparent
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite */}
|
{/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite */}
|
||||||
@@ -65,6 +69,7 @@ const ProblemSection = () => {
|
|||||||
{/* LightRays - nur im Dark Mode */}
|
{/* LightRays - nur im Dark Mode */}
|
||||||
{isDark && (
|
{isDark && (
|
||||||
<div className="absolute inset-0 w-full overflow-hidden z-0">
|
<div className="absolute inset-0 w-full overflow-hidden z-0">
|
||||||
|
<Suspense fallback={null}>
|
||||||
<LightRays
|
<LightRays
|
||||||
raysOrigin="top-center"
|
raysOrigin="top-center"
|
||||||
raysColor="#ef4444"
|
raysColor="#ef4444"
|
||||||
@@ -79,6 +84,7 @@ const ProblemSection = () => {
|
|||||||
fadeDistance={2}
|
fadeDistance={2}
|
||||||
saturation={2}
|
saturation={2}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<LampTop />
|
<LampTop />
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
import CountUp from "@/components/CountUp";
|
import CountUp from "@/components/CountUp";
|
||||||
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
|
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 Process = () => {
|
||||||
const steps = [
|
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">
|
<section id="process" className="pt-8 pb-24 md:pt-12 md:pb-32 bg-secondary/20 relative">
|
||||||
{/* PixelBlast animated background */}
|
{/* PixelBlast animated background */}
|
||||||
<div className="absolute inset-0 z-0 w-full h-full">
|
<div className="absolute inset-0 z-0 w-full h-full">
|
||||||
|
<Suspense fallback={null}>
|
||||||
<PixelBlast
|
<PixelBlast
|
||||||
variant="circle"
|
variant="circle"
|
||||||
pixelSize={3}
|
pixelSize={3}
|
||||||
@@ -49,6 +53,7 @@ const Process = () => {
|
|||||||
edgeFade={0.5}
|
edgeFade={0.5}
|
||||||
transparent
|
transparent
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
{/* TextHoverEffect */}
|
{/* TextHoverEffect */}
|
||||||
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
|
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const projects: Project[] = [
|
|||||||
{
|
{
|
||||||
title: "Email Sorter",
|
title: "Email Sorter",
|
||||||
description: "Automatisierung · E-Mail-Workflows für Teams",
|
description: "Automatisierung · E-Mail-Workflows für Teams",
|
||||||
image: "/project%20pics/emailsorter.png",
|
image: "/project%20pics/emailsorter.webp",
|
||||||
url: "https://emailsorter.webklar.com/",
|
url: "https://emailsorter.webklar.com/",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,6 +90,8 @@ const ProjectShowcase = () => {
|
|||||||
<img
|
<img
|
||||||
src={project.image}
|
src={project.image}
|
||||||
alt={project.title}
|
alt={project.title}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+18
-4
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable react/no-unknown-property */
|
/* eslint-disable react/no-unknown-property */
|
||||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
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";
|
import { Color, type Mesh, type ShaderMaterial } from "three";
|
||||||
|
|
||||||
const hexToNormalizedRGB = (hex: string): [number, number, number] => {
|
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 & {
|
const mat = ref.current.material as ShaderMaterial & {
|
||||||
uniforms: { uTime: { value: number } };
|
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,
|
rotation = 0,
|
||||||
}: SilkProps) => {
|
}: SilkProps) => {
|
||||||
const meshRef = useRef<Mesh>(null);
|
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(
|
const uniforms = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -150,14 +162,16 @@ const Silk = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div ref={wrapperRef} style={{ width: "100%", height: "100%" }}>
|
||||||
<Canvas
|
<Canvas
|
||||||
dpr={[1, 2]}
|
dpr={[1, 1.5]}
|
||||||
frameloop="always"
|
frameloop={visible ? "always" : "never"}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
camera={{ position: [0, 0, 5], fov: 75 }}
|
camera={{ position: [0, 0, 5], fov: 75 }}
|
||||||
>
|
>
|
||||||
<SilkPlane ref={meshRef} uniforms={uniforms} />
|
<SilkPlane ref={meshRef} uniforms={uniforms} />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowRight, CheckCircle2 } from "lucide-react";
|
import { ArrowRight, CheckCircle2 } from "lucide-react";
|
||||||
import { LampTop } from "@/components/ui/lamp";
|
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 SolutionSection = () => {
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
@@ -29,6 +31,7 @@ const SolutionSection = () => {
|
|||||||
{/* PixelBlast animated background - nur im Light Mode */}
|
{/* PixelBlast animated background - nur im Light Mode */}
|
||||||
{isLight && (
|
{isLight && (
|
||||||
<div className="absolute inset-0 z-0 w-full h-full">
|
<div className="absolute inset-0 z-0 w-full h-full">
|
||||||
|
<Suspense fallback={null}>
|
||||||
<PixelBlast
|
<PixelBlast
|
||||||
variant="circle"
|
variant="circle"
|
||||||
pixelSize={3}
|
pixelSize={3}
|
||||||
@@ -41,6 +44,7 @@ const SolutionSection = () => {
|
|||||||
edgeFade={0.5}
|
edgeFade={0.5}
|
||||||
transparent
|
transparent
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Hintergrundbild mittig, auf Handy maximale Breite */}
|
{/* Hintergrundbild mittig, auf Handy maximale Breite */}
|
||||||
@@ -54,6 +58,7 @@ const SolutionSection = () => {
|
|||||||
{/* LightRays - nur im Dark Mode */}
|
{/* LightRays - nur im Dark Mode */}
|
||||||
{isDark && (
|
{isDark && (
|
||||||
<div className="absolute inset-0 w-full overflow-hidden z-0">
|
<div className="absolute inset-0 w-full overflow-hidden z-0">
|
||||||
|
<Suspense fallback={null}>
|
||||||
<LightRays
|
<LightRays
|
||||||
raysOrigin="top-center"
|
raysOrigin="top-center"
|
||||||
raysColor="#22d3ee"
|
raysColor="#22d3ee"
|
||||||
@@ -68,6 +73,7 @@ const SolutionSection = () => {
|
|||||||
fadeDistance={2}
|
fadeDistance={2}
|
||||||
saturation={2}
|
saturation={2}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<LampTop lineClassName="bg-cyan-400" />
|
<LampTop lineClassName="bg-cyan-400" />
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const Values = () => {
|
|||||||
<div
|
<div
|
||||||
className="values-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] pointer-events-none"
|
className="values-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: "url(/backgroud_effect.png)",
|
backgroundImage: "url(/backgroud_effect.webp)",
|
||||||
}}
|
}}
|
||||||
aria-hidden
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// Parse a CSS color string ("#RRGGBB", "#RGB", "rgb(r,g,b)") into [r, g, b].
|
// 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;
|
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> = ({
|
export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
|
||||||
squareSize = 3,
|
squareSize = 3,
|
||||||
gridGap = 3,
|
gridGap = 3,
|
||||||
@@ -65,101 +74,10 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isInView, setIsInView] = useState(false);
|
|
||||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
const rgb = useMemo(() => parseColor(color), [color]);
|
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(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -168,38 +86,151 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
|
|||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
let animationFrameId: number;
|
const cell = squareSize + gridGap;
|
||||||
let gridParams: ReturnType<typeof setupCanvas>;
|
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 updateCanvasSize = () => {
|
||||||
const newWidth = width || container.clientWidth;
|
const newWidth = width || container.clientWidth;
|
||||||
const newHeight = height || container.clientHeight;
|
const newHeight = height || container.clientHeight;
|
||||||
setCanvasSize({ width: newWidth, height: newHeight });
|
setCanvasSize({ width: newWidth, height: newHeight });
|
||||||
gridParams = setupCanvas(canvas, newWidth, newHeight);
|
grid = setupCanvas(newWidth, newHeight);
|
||||||
|
needsFullDraw = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
updateCanvasSize();
|
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(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
updateCanvasSize();
|
updateCanvasSize();
|
||||||
});
|
});
|
||||||
@@ -208,23 +239,28 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
|
|||||||
|
|
||||||
const intersectionObserver = new IntersectionObserver(
|
const intersectionObserver = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
setIsInView(entry.isIntersecting);
|
if (entry.isIntersecting) start();
|
||||||
|
else stop();
|
||||||
},
|
},
|
||||||
{ threshold: 0 },
|
{ threshold: 0 },
|
||||||
);
|
);
|
||||||
|
|
||||||
intersectionObserver.observe(canvas);
|
intersectionObserver.observe(canvas);
|
||||||
|
|
||||||
if (isInView) {
|
// Lädt eine Schrift aus dem Font-Stack erst später, Schriftzug neu vermessen
|
||||||
animationFrameId = requestAnimationFrame(animate);
|
document.fonts?.ready.then(() => {
|
||||||
}
|
if (disposed || !grid) return;
|
||||||
|
grid.textMask = null;
|
||||||
|
needsFullDraw = true;
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(animationFrameId);
|
disposed = true;
|
||||||
|
stop();
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
intersectionObserver.disconnect();
|
intersectionObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, [setupCanvas, updateSquares, drawGrid, width, height, isInView]);
|
}, [width, height, squareSize, gridGap, maxOpacity, flickerChance, rgb, text, fontSize, fontWeight]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in new issue
Block a user