From 5cc8b5f66b3f19551242ee462e9dacd108aabc63 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 25 Sep 2026 21:47:17 +0000 Subject: [PATCH] =?UTF-8?q?Performance:=20Animationen=20pausieren,=20Foote?= =?UTF-8?q?r-Raster=20entsch=C3=A4rft,=20WebGL=20nachladen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- public/backgroud_effect.webp | Bin 0 -> 15550 bytes public/project pics/emailsorter.webp | Bin 0 -> 10834 bytes src/components/BorderGlow.tsx | 24 ++- src/components/Hero.tsx | 27 ++- src/components/LightRays.tsx | 8 +- src/components/PixelBlast.tsx | 136 +++++++++---- src/components/ProblemSection.tsx | 64 +++--- src/components/Process.tsx | 45 +++-- src/components/ProjectShowcase.tsx | 4 +- src/components/Silk.tsx | 34 +++- src/components/SolutionSection.tsx | 64 +++--- src/components/Values.tsx | 2 +- src/components/ui/flickering-grid.tsx | 278 +++++++++++++++----------- 13 files changed, 422 insertions(+), 264 deletions(-) create mode 100644 public/backgroud_effect.webp create mode 100644 public/project pics/emailsorter.webp diff --git a/public/backgroud_effect.webp b/public/backgroud_effect.webp new file mode 100644 index 0000000000000000000000000000000000000000..bfae3d448513280436c2976768f7422fe7a9c2f9 GIT binary patch literal 15550 zcmY*fQWEx|7aGcmAZts3=K_ ziM8?o0P3Pb3Tg@*gq8o;h)lq_K-2(G04Sd!Ly80`8F^I^3T2=JER?0)Pq7}9tX6fY z-uKEUGsTWA?-5AONQq~`Pw{D=06){_3KiIo@pxa+$eDJv4a(2^dyX0JgmJuN!bCn(*0}P&CwX|pN;Iu%pal>(WKDeX)?uq%Gm zi;xH_BN_68;~4K>f1o4y|MA8i7y3p#NxYfFaxuz!uvL|UVECg{r{7xs&FG&+wX9=) zm+QVAlucZ2S&XEL3Y5{S|Cg+Q7gEZKl22@P(!Dun63QW=5!}R{PHY{Wm%$?mRqelN z$QN~bg+l^mln>6(%^0>~)10lK*crgm#Gm;6x6FS6!|;I;Sx@I7YXmFg)y}}igo=|9 zu9!B63VBOAQy>a4<2K4xLsnUVBVPa39=QhCEy?ReKQwWvKyyJz*Q<}&*HZBP7b`U? z=hL@?Ut!jzZ&;%*eK`{4p-pBKpB5)PlPDRu>u<@ZG04{BKB~m~PITuHN23 zP!ygEp@dY~@Ad>yN>~|!g-UNxXYWF3l&c1_4C&W6US48z}@7{`0A_V;C6$pCp|sBCO^dBIhQ`i=O)(ur#+Q0yhH z)iv~(C@IywTOTm$YxIO>(DM*R1hKKpHx?&)KVLf9ba)HG!Bl+AQ`ql*CKb~$^K&8% z^Su%(zuQQ(ZuP`nc?d-XCthxJv~>zk96Kpvpb^1_Gyg`12pq?x4fY;GdfPD0j4`Pe z7adtTQLY-VPCDjk4C5-pa76`>T3+fJ286uzpa0BkJ3#69pY&T2Vw3m0T3EVerB6IdKtc)Vf#}^5N0@j&STa+-}B|atEK1g|Ez2 zFr+e?k0i25n1|@k4KxJ%z4iUn=e5?o+q&UPHzkS2G3`x+a$o0 zh+t?^GS1+9;zC+JrQ1vxE%*2{?eGy~0g%qOTQi>X>xF`yW*fe7lgiX85D+E??K2W> z2Ykk|7jEO5R^;q5WAq~|aL!+Hb`^n?Af6-%#h#_Y@O>>KVHhOTxBnaHm#}TS?138cv+HLyT)y z{F^7g<&co+FnCu1snOTp!kjxH=x@Gnkk0UDv{0;xO6Lpva3WO7a!zEsIU2eSIWCAN zj*VHix8lOshEG~KEf8wkQN^2^WqNWgBzEkR6z@fe#_w>cLek=G=2?f9=@bombCfjZ z#nTLA8s*~RL{q@i!AP$&I{%#FRg-UVl;_VSevmGGe-l~F4&R02A!X2|urbP&U_zoK z-#Ny{43at0kZoCG2NYyXY@HpRk0=~0CU1&j=odJJ8sbsrj9hg<6K)QlJeCNZ)mFfn z1?j-DA+^B-nV7cQXvg#>6K)2)5DVT#P>K9F%MO z1gV;X)hRjY1HY^3?Tqkf=P@3^-91`{@GOs(e!9rvf8^?UFz{y+J$6kBO=# zvxL7G&|CvDj)tu0Dg$r2xe3|&cXbcuOy!a%Yv1kVxMV9%)a9AiERXg?lH2Y+6?|48rY04c*_L$uK{=- zU*Xx*K>|X@ut7e{MQBIr88N<$ZU&;H)9A>2&dKnQ$-t+Da71*wvc|$+%X|At8Ev_W z@JR_R(#sgq+@G$efklK_;RKIerE*oWQ)|e;e3#5I+Hiq!`#Cwg{1;NqW40s);dVFH zVA!?Ks}T%Bn~|ypGi<>GoH=y)gtJ(GvS())z?}kPL`S{GP5F?8c&2duC&%j=E#zD) zb4gu;{i+2DfG)c|fb!6^=*ENaE3WR;Zh>Ro$8$3+VPTSJ!-ElPu`-^6#`v)5* zkQXfCCyL_z&Fy!ZxR^x$SUum4R?ysBsm_(#zOH-KjQuUF^%)@p2qvg?_NQ0tx-@41 ze(`AK9yPWhP?ItE_M=&H*u-vf!{4U3ruq}X6DrJrZH#GRsDG2D4j?rv`BcG|>7VrM zbsE50E#LhbIh$QTsiUt<>zYUO++4Xzr?@9sKf%@%1h;bX#{IDctp@jQ6wC{9K!)JE znC&~YfxxtD*t+E$XX;?934>i_uA2Dz9<{0`yLS>eV3d*T(be*CKre~->gaF<7I~Zn zCCNG^(6E`1$X^k73qV!b#*>s+X!haQL^KbUvA?1xhH8skJmCGikaAM@#|&*9nHL5Y zGI^)GicB+ZUfO!xSz|TVfb&{yI{lB*GVtMiPA;!Yha!^&5Yy(}OQ~f)c_dk?rQM^8 z9vAToTWm1{v)3YqsQjGcE)Bo-1>8#w_w%93T=C&nziPPTJP0} zPZ==LA#>G|UcjDAr&jLep|o9kM5iax-D50G8kMm>CmM72yy94#(0o{&N+ivh+J#q| zobTGC>Fi<}yEM(Tzay_1%f5OEivogYY9)D$9uHjL@)XTz5owOB**(tWPOz)-V8gNj zc9C!F`WVv456zi73V$^5L3%&YZ$LE|3;Yxq?nF6e7!I~O`|62yIquqw93zQ z1**P)4iKhVuH&KP&jM7tC)XVGtwrY;LfgsR&Ui~wwcac`KE=vI0VW+o7F5z6wbG22 z^u^Z5&@9rTH!o2U=0$u})kCFK6&49X+H2j45u2d)sewX~3#=>+i1!3fEz-H8tI}-p zAjr~!4qH{G-yagd#uP%`%4bJnGF%Rc=kW!cuV!oT9v$&b1+1e7*-v$*EpztKqdh8S zosm&(TXpU*k*K|A-;intxmzAK&dg7&)^Zx`mAaKV2m9Gaj!MT3f+`cFNc_p!)rEw* zPEH1~O<;dG7(zte>GQepREoc;QH87)4M)yr2@oVhz9qzGQkPzaHNeThSSzu?k_< zC3rBc>s~Sd1qVTP+}mUWG2y|uhHYo#9q)MH2U?H%d4~qYGxHkafEUR3tkXyr407r5 z_)^6;|(1n?hkgq{1m|5e2Omxv8WeTI^coiHH*`9WS`7?1i z{an8me;Ly7{F>Sg0pK7|r^&8(yQW2xlH|2wJ(+|z2yA?f8PqqVG0wKlYm4+XEOAqv z=A-S_%d1bSL9_tQ{;tr^{^ujPQFV2;yA}67aiAk9QZ7` zQVSn~v+L3lpto@$(DG8%z4fgzowdDkS=xJ7g$%3ypf8B4P9ubAgw&Q$UwQpKSkm1p zRwcQ6g|{PZ+>Ire!@N>*v7rYN?^pol_3-l}eds%IAg!%D%%c5Ppzfk@0FMW6?Calz z*(Wk2d!n!tc<8Zj)(T z6W`|U)Zp@Q?AKGH&u#OqtD9e;6BGI{^|aMf{5DNt2!s<|Rfosj%oFxt$ag2)+VXekJIyW-Nmj{8;j^LPxN+5S_^$bAIqzUO zjef=egc@nbz+~^_`77D%i*|AAcbSzQ-o6CKIbfJV@UnDn2_cZB^Jw;PSoltHj3l}k z#m6*({B^U3_UC;I{PDI`jL(WVh&dOaWyvcmr&wvHiik*18RVEtd)PIk+M$^HviVkk z;u)-nC_X@^`=+|r!&=lH=1>#nBWFqYOZZ)6cJY2Sn0g?qaSr!Nv%T+Yl@ryq3uk6W zf8idKpya-eFNhdAKU9fgnm$-jIZm%;HW$Q@Xqs2uPC{-OiGLai=!20b{DT@t;;rcp zJfe1F(f#((y|_OaC>R>l1%;zH>y-)YdU7t@@T2|m8|<k`E$B(Ny{rN%lzuDZU4K0->KX2UMrMoKkX#%T7KotgC zvM&806I;EBRNd}xFYhN{@u+}VO_5BWC%Be@>g@7gHoW5dT+&Cz-c(v68}0k>TxJap zgnoD+-(%mn63@xs(CCRli0~g;9n_q}cWv|NlrhTaS-8}$G7AYYFBAXoY5!EI0?M&oOinU$%>v9?M&FA(;SoFq|6j%69}VLl zSs>!7Gv_tmo7`1j=m{y3MX>0|I+JDTUplV<0DOOr*8>26QiFfG|82km0Q~>hSVkAF zyK@$o|HJ`#ylP)YOZFlIQ3U`1$E71*=I^>WW{|KHm;d+>V1|Ww(*%=uF9dHnOQb^p z0I(>nGENB`b;trA?{24U$(&`vE&vFW$MkL7h;^X~6;{lg>bX3Wh|ugX&QY3(`g(4h@g`5?wo%Q)u!5GLc+cW0!lT| z&}-72ED+emp0aY4Y}iYQ%rm0cr;VvI?vdDod(n!)vNDKh1)5`f78q7A7UMusMI5yZ z`F*`^N|Tm?&nW@A#3}c9Kip!#5I(}>!}{#hUi(gFrycdeodsoup{jTWh2Q`Kf<8=) zckK+{QABtSV;@OYFk?84_>5#w$VUyl1?yTN?~jP;=&IxBcDCJbyc{a6W4#LJnaX^&YrUcm_Sm60|CE7!Bl9ySVwXMegV18`E{tYvmZjImgYq}68nAtL#`CciSE zKV$q;mwu^uB|nD3kznSJNI9yfIk0L%YydkDKn(T#O_o9!#`?@`qeU5i7JKqFPPtUg zDK|I8Y)pbDXCvYBg!F4Mv?budj2G5(R0sjx*KOw3PDX8!pOnNp^-zn?9jGy3Bv(AO zTZ$*0x~F?N=wVkAC6nk+y!Jqt!U=ztQtrO)`EI7746`kJtUK54yN^m*sN9l0*9_>k zQHAgO*zh_fj*nsU%7Mv9{IMdcec;4ps(%JR9wkAvGykPNYuojN?>NIM%p;A*!>j(d z!G;YxU^Wj4+ZdsUe!O;cUtZ&nm7W(rG)tLEUG%SYI>37a#E5Z>QA@_}O0rd%!&h|g>ZZfI|3G#gH>}sH%H6Sj5b92-6ZQ`LT zuw1>&C>66eP{kvL#^3rb5;Y9{D{Q_3{umGk4}`4d$hzbhb&FVhoyjIc#taiNZI#j^ z-wC}$W+M(;WHK%7nV0=HeA(#ui8?-&ZGZYxT-#7063V%+Z7vLMMJh(-QE?^c5!Mh#gZ%d}%#9T5KbQZARL`mub;{-}fv>FG;S=Qa{m3|4I3s}B&2A_|)V|87Fe zH<=df=_`cH02M&6K;{0pFuzn7mu}OPc=MX_r`sms6*LfzswjTN4V;)pd4MGO3Wl() ztt>#No^|GH4PG_Jrvo#7z+843E#vN7Vv4B^$#IpqEh7(@7@$P<<+)9R@%l}Fy0P@4~Obb3V|cQG?o5( zeGAqi7JG)3wPUZ=r8e79P+NmTJkeBEDkQnm#`7gC#9de4;bFQ+ijS=6Qf;efJAr&h z-d;?-Zgx>Q-FvK8n_ zb&RLZ5kG{XLtsCV^;XvYNg$sPZQ$M%gm5(7v5;U4-FZUozT78EU0;usBLKJG#7x8OEWL3$I&8r>zp!ZV zNzJ2Ul=yV#Mac@YcqY6;Sv{SLlo*QoQ?~sN-Ji*7mE4^V5Kt+9@49#!^Eq|eL6U8w zF8i{vw<7lFoYpG(xnxcrb%Yiu(QN1-N=F9P?~{rpVK)YKcrm!foE7MK{FV1DB5e{( zZWX3om5H9`ASGGWSV%T^+Euhyi8D<4mZuG%f&bL}Wd&iNe)G$b<5ddeXR9y( zPu3WG1aHmBk=O)fr&3>Ky4hF;I9Ywv6bgaL4o*pYXo^*l>ze;!@T5?X=+%T(T3ZZr z;A<_O+IG;~;U_?#GCBD+Gn<%B=tPXAZ~-R(*0OE}Dh%{_rPIbX%;*+rTj(fnODOMC z4Yk=Uferd8RIKMttL2y4uiui#8GeIeEiOh%rlc@w?6gXfSwwk81qG)fu}pl+acC>b z(#@+x#c+X5ohS<`e2y;HG1Xj`7I&R+sGn)XY-W&ueXp)X7Yv;b9dkk<i70G;gQxv|!Rqd;bjsK#K2X>HD z{$ldn%S6K^D7{{Y@;%^ndw?Dv2Tk^0hR2NBob#v}LW8*t=2UU%)pna1@>{xknZ8~+ z`c=dWlISI8tRoTdtp)5l5GWE~?_Ix!>9l;N3Php7#Sj#&w8x269mGgFmVrbr_Np`+ z-W%*L`W*6A1WuF4MnhA1;7_mP&-raxHz4(EDy8l!7@NH!A|0ufrfLH~XKGgQ+an4J?Y3>jPf zw+}2jBvrFm`v6>T0~OUuTp-MtI*3R=xdAZ8wv^#4_=~DP(rH_1^4o(N>2)kCSiw%e4JWr;35{?QW4x*e9E#&hwkk) z$Avgwyf7eesww{dajyhRR3p!EA?y}qXw0F%fR9@%t6~;@ww3 zLpz3d^swkLQJ44&zOi*Z1MipI^hQ4|Sgd#QNfB1p%c3k>oO4=9jnN`y)iNwI=q*DW z9in=@K+?v^?Fke!OzG<+_3;H|y*XPN%+~t@yG47Qp8d&|*b4aR-CVD1!Visu-LoQ7le4?GhF18LN54T1!eKn7oXO(M`pY}qqdSo^$V zHKR(l0x~Wd=bN8>tTn3{)!3H}*Xf#!LoMU&>L)RY}!?MidLpa%@;a-#ymiK(h& z=`io5&eN?Ocbt=;dd|TPfG-`)({Zq)K@yXB?qBf`oxa)?S0gYvKedL}U~`SB!*Pjc zxDCLRM&QR1^R`^3&kgXxkYEb}_cQ+#z4bon_lhQVhh%ScE}}6pq}u^YWxqbFZMR{oOw>3c{{qPdD}eo^^-5f&K%eK2U+VLO4zXd|7B>5m z(+`E0#M_5OR*H46^l;7~%0z4(&VBBc_Pjq1O!rBqJYtE~OWZDGeQ-lO1iG~8Zsd3s zt0%ll)drS%`JY??%F(T!25}W`F%2HM;jD&@=Vl2-%L$qeZ3v|=1=ThAgplDIZzVZ$ zP%rG+V5UHNa)u_9>HBdS#Y*|9?B>s5)W=+$+r?Dh)1^~hH-@&w&A`3{W9;PuTkl3q z&WNX^$1d*V1lA)bOD9xa1eZ2=lc2vrvXIlOHIi*DMrb6e3*OM!b!0sRU=pJkfGrO# z9`W2n0|>E5Z3&oFLkSWW)^^~HwyY>3YE}r=2Rs8ss@a$d7r25K>awDO=@BMGQY@lF zQE+bgA?@UiB`y>ZWdiTHoOm72<`6KLyVmHs?TiVV9Zmh6>?m;T{fH;fbP%1b7~|%< z0~%&uG_87fDu0x>tvLI>@2mS}LcO~I*JgAq{c~IU9TFXqIc?+a!P4DTR;*mtdu*p` zC&A~F#c<_QC$e)6;LA&AB34$p?o(8k9;P6jmNps#&Q7?Zcu$A?3Ef7J{#T(# z{e~q{7DCZ=#^#h0aB&roBc?{x3J91{GAVPF!}iB+o3P@m65w_2kQ`q#q%~@&vlAk- zg51^T^U)(1G4z4=u8Td(rx&XTv>z>Sjz(?84}W5knf5`izb@$XIbyTeH)PIpw)INq zhqWqK8Fr*?Hg6|wnJ>OoVmr{KAwF!I53r)lNL)_SiiEJ~qC9sFA36aa(qBh$x-l8z z80zL`R6Zsplal&cYe&Asnig!-N_xQ3L8qnec?b|Z>a&!Org-L(cpHoX-g^ouDfZUH zvyOd$9GdlGC^r#>nc+4v-4fEa0@0}1G$KS_e;8QDtWc25VHOE+>IEw=qll`iT4`j7 zJ9`5m+VLz?Bw7tp`cX|y zNrbP}&A*7j zRFpV*ps;s2uYU)~Q6iI;un{QZjAdi1z#;=Cx}-bDAoOotPi}V*@Q?`>1YYEG1-s>Q z%M*IKNzgh^6>FTSOe^XFax2)J-&l0pusWfB$nDMWcAPOK5(~O*j^7Iwb<_Nt8ii%+ zc&^Ixu2S10GEYOhIYKlJ3WvYF=j;wl3$)A5|J;0k_^%v&v|rIVZXc4CsA6misog*& zDiodVle0UVWOqb^Z#VYG$7`bBM3Rg8;^Ys4R8{I?&~E6Oi-3J>6alFxSRE1b&))kP zpIz$V{>gE->kWT#p@Pbpe}^<#rt}RkaV>jI(U^RxM7hIg+;JQBdvBawC~!fIAyBCH zY;1f-CYI0i{^s`<_}ikGIXCtQ>(FG^og>rR;kG-x7J@J0iS&nc@Uf1gKj9Moi3 zn!$8IEQ9srrJr{j?d1~8W2+^fw(3(J8`9G*30X=k_3kIJMC@{kVO0JHIQREegyC}f zk4SCw{+#TUEM19kW3_yL-xvS*&6wf|HoSd z-v)dC;FWxN%G>tUST{wtSVCCjwS@o+brkElFi1tFrRd5w^AMx2H_DFhZX5yE1$x^GFr>Nq)=aKD`8 z#5|Sxp(Jt}Omg$G`ZV>AiptLUG$#TZm%Qhz3KHElPpE(on1&^(VYXXaVQwdOYBh@= zEw8?zx10CAa!67YCPr!^Uz>l6(?Q3JV*j!U+9PHQ(kXe28}))&+2G!sqi0BE&NVL( ze`%zVG2dv))rz1@kO;)DS`wsF*A_k5^7Gy=6}2H$de|SV97cEv1c??ehyG{#(x;Nk z%9_YHbR!QHm9pVCV@d7@ylVU&>P$3oPcx7tH~QN>(ZuJn`mDu2b}n_ZGF13j%JKoF z-dcTR2p|2KqNBFLScm@W;j9+(@|bq$zvi_oH>!W{Jo12<@r-Q+^6QOxIS=OHIKDf{ zjma4l)uhfMn^jnw6VNLIa8Ojs>Z*%BtM8)t?6um%-O(S$&MZO`QXgR?D~%(v zgRDECWf^I?WO3=|3oUsJp_9cA&LoOS^OZ8{+luu}IMIs0-_JwRfUsy@!l5DHRT{;Za%L$iw4S16u`=V< z?=Zq<#t(Le>X(i7!OQ(-CjzKImb>M96p3{GkJ0PlW@^|>UYkk;O+#w0W}!-mQoyC| z$Ga|s?~FyX4fZuK0X>fE5Gw= z)`t6aYqqsnht)Hb)$_WMy|<8K_sU=Wg1($K>YtnbOMDot*Wyu>&9ulv$&)U5G%x?z zzz;G~7R+y4(3d}q(#>2(mL9VplH_M-B>utoi>dDD@6Dcr@b?1hs?$Q_CoPeQe7*bJ zc|(#9-HPF|eorRfw?5f?u!?Jblm&eWP1REXd``3W(inuLlU2i0zA0k-)~yUND&-Vy zsjQ?Db_e;y{h2W~X+7Bu=ato$iJ>v0NOy44L@|p|RVfE{UllihBlKogD27W!4p(tq z?8}?&bW8wCwDgV|-w?L}f+`FDGQTCXQB;0yOdoClA!AxHkp7sz;Bb<;hr~C`nBMP% z)v6c#C3m&y5VfSVqHjj3(r}I5v$@~N)LEi(12sQjQg$e4f$+w%ukwXqKOIZ=2 z`?`0V3uN@Kxaxm)0T_v`VWz5${J*OCAv#yrjXlPfzOEI{7es67r(~H6hFohh0=iz+ zBg|4rb<0MH14md|kFKh-3(stH12#o$27Zaq3Vh-xIp}4B6XI{s#an(|4x#8d)r$h! zjPaK+ol<4%>fF97TuZ&oJRyTS&zfQ1tUtnAV5NO*MM*Cu+W?qB^wP%@sRR#RATkXV zzd-7*w*%Tr+*cAV^Vy)(N?4B;qfQW|Qd;G^%X{(d2-uypUdc_2YKr`MYw`Gyc;ow| zyQ@t98k&ghAI1o_loOh(>&;y(>I6IZHh)g!H6FHSi>|o3$Fyak&Llljz1>L*cW>*a zPkmt9s-yOXMRLE2La}$CI)O8Z(3T7KE4wfyCsC+wsO*B1WV$I#3sZuseV%o5mb_Q6 zg2%b7zNm;}>z2mX3QH@5PG2;}ddp;bGk0rPjP1q5ys+d4j@3o9$@)Ot%uJ`BVmb8E zIz8E?*_Xin{ovydf#A5m7QXn|rfB2}e68Lw+P8X(@+zI`!BYxGlEjIkX~^rXRSk%; z=B`j2%WgJK^AHx}Yg$ceoqx>}CatA-t9oBPtqfGpE1IMWg&>dzc_s*018E%4a?TQj z11sa7S_?M4&~G5Ix}{n+@cT-dPQE=`^%0VgDUc6Or)*PQ10K)M@w`FO6+^3Gm<-rz zBeOKQgJ69RUBvX`fj9$CUO)(mwc5P86#4;MU>LrVT1RK%jYfUXoRAttBRz`Mm}M+c z?%s!j*RQlFgKYT*>z?LG^+qE&%?jjDWN#Lk(+8qbE08nJmAM>F6^H#=o@}p)~cKN zErwtz{aJD|fMejtC>hj1V~!rQtBo?LwN^v7U7&5gt`7L7U|t;czQ!!OO|Y<|EIu7s(ra6lGS2uDz{ zM*n@y2ad#iKm|mdv~$fy%K-sFz$A-XvWzAkSKcv<`It7k-sqF3Sj}zhZlu5efe&Qw*JM7-`8vms8UL4JVbfbZjZl?Il^n*# zC|v;c*~xwe^cJS@lcbR&;<1x>jh$?8-CvU*BH^nh8Rm2S2Hd%QR8EDnzaaX&-Yng&v5LVN&J+E~A#F4JQTmUcIj6X3>2bpWPcY(xEh>=XXaPicb# z*N)|qzbt7Q$dIrxX0=ly{S`J^*d0huc*f>O7nloP+uUf&a@c&CW~Rt}JffSe=_^X4 zI4eslUig5f)x3>^i}#%@?O5vG{nN@7E6C^cFv16=RidCX%z(%{iNI%b@hPOL(vBg! ziCW9|7gRUd&2pO+wF;yz{UTRDJ5Ug*kNH^x)6YE))n3mlQ3$!ElyOjU=_#4N213tu zhK0tqjPX4p{+bx)p|z^babj%kO@J zW1){K&3PKd3IU7gtE2pZNH<|`4KVrQm_^b`64gtQIw*$4~YG0@4zbb z2r}10%^wdCO>y_G^n z(yHm70r`8p)9-7Wi3C16NfET~Jnhd~g|a&0KgrOn_>bzX+ZB~dARdV^XS*3%M8PQE zi$c^WJPE6N6q;na3mVAa*q$#>9}Cp_5w8@nWtS3{Hug7-?i*wWsjrL5aClwNWZB{L z2MfD&&3H4{pL&W&3y?XFFku1(Yn&AenW&ta^JTjOrnSus$jm`s z0moSF!=VB(C!W8y1$4Ls{d$9!sOp@hFk`}`;*@ZthR1u*cWuTyyx<-VpWSIRQ&F;O?l{)1k&vg$ zBiI8VtY8T{1caw9?l}Pnjbsk~Bxtz|BJSueSSyly>N%Ae)MEl|Z3z3nuwICBu-dIA zF49&1knadEWTb4MFCN@%smSaO7Zf+cyNjU_P1H=o{?LLX%X3(^p(6iImW-7KVQ15C zzXE#vNt-#_JdaJ6g3TUsu|ozJ)+Vz^<16LVQ#M+d^~h~JTj zHNp&b1j3v%_-ieImw|Q;e^dd^xZqkH^UMM_X;fikcJ3dv`*xacm~Ic6d6S4)b2Ee~ zBOSSg*+}u)J45suBud!0U_?gDcK1M7^&P_xkx7XH)p3>-Z{t+@-s5QOEpnP4$A}DgDahI#E`nEKczYsJ!y~GJ4aclT<;q_?mNhy&$xtRr6 zS*5!{Tlm{c2X4~ea)CE~Fy6+O)ab%2EY>vheKS@m=#P%vC#p!S!`X4P$z~&VX}K`n z1!vKcaz8?k8~PaGYJRct_tf+2Z9#qqz0e?M)#%}^I{uL8+_5nOuhN<5rP>-t*Po*E zEGe(hz{PsYv$2m&W-W@&C>0MWwZm}Re1}tY zFGqH%J73A=Tqh8#(F$z0<2Sr@nKZ5ER9%neew-!?cY4XbeZGA<3NNa7=(&kOq$lH1 zC%$3+Xsza|a*9EdR7A&G_dMp$Ko)$b1N)W*FSMC^7wbe%t>wUJKO4MNmSqCchI-)Me(Zp5IM^n+FP+Fc(ml#f?d5-L{?U&j5Y_3o4^^@ zDpp_tx;_#r;3T1sYSWW3-F)V41n3Dnx6fsdJ;g0VYCy5h^#fIKU$k6{3Rw)| z7h#&Z>nxVBz&*TS?{<+0-&7;i+aP-1%v+M=QN(m~AH4w|htAk~jxoSSYL%*X9$5JO zP>5!L9*P%-TBJ>jv_oGg@192q)ApK!GF9v(joz0gVMzn6r%(zUxj%rO5r_lf721zb z|00U+>$NIs^F+cb(ES_A&K7gIRQO}pSZsoawaSw*p7+^Fm7n92O%Vt+S)PhJwD!?^ z#RP|cB}MW~aAcWo{imydDg#2l%8C>^Yu!cpax%4s_Y*_7C)6-lTi8b6!Y*+yVyP(l z?AA-YiP?P$3&8;V{nrS$Q>yk(wA^@53^`xT(QQUq8>{hf=ExVK+3&d&0_ON^=yb)@ z9*?-iaDI?;x`O)a?7J!)@iTFZx^~7Gv#zkSFJufK>j{Yk}ujng(NN!`=uHd`3VtIv4PFFoZW}wlz#XQJ2mvA+HXbLh~r#z z6Ugp&CUSp9_H= zJpB4D$j>0U)o72LP0^IT2L~UV0EaA2*=6G;z{mFY(d){WtcuD2ic*M!GbjrN6dI9e zM0Mp^Q?H#+?5FgkiYVbmt0lT?_r{q9Fei5>;;HO3e?gwBT(FL)rg~cBVs;KaWmnOU z)yUZjQ_3MniIwdQ?*-*wov_ZcBCz;xoru_gQI>k+)NX^k2yCIG$eY3jk9|5!mZp%d zEpicxOsqu^!Rn@K6x~kEi?ikL#OzNLJAp}~t88G)Szay31zp?cK7vTf31}=Wz#lh;+2e-^KRaaLwDPQ7M@p)!Qbj@^sL906YCxF-*@mv z;QRvfk7U3~_v${CVw^EC*1_gWt#wGj;YBC;T=$iCybWS7vJ+h{rtJJO!&9_#^ah)f za(CpWIAxkgQb(Pk%y~^36Iz`)M!+oKw&f#(M6}67 zjJ!&(j3;1T8m`?rp_qvUxm{F_pJW?9;*9Kxh`l| z{mdUKR{A?1kr7i7{o;#$;QqHyp!o2YhmBmAEd!LC@LO{K(puw2eR8ylsvR^dd>Q{S#P_`hZXr%t{ZZaU1Ot&Ua3e`wnNswN%7WUV^4l?}RbZIyy_(r$HF z8iS8_veO>5mAJU_v`^cmuind|@SHnfQnhbH*tA9P#a-E{K~JE9UXGWVfb6i?{6+Ue zhNtl0UCmMRS3Ru4&AUZ(56h^n-!1Q0ScSJNE9-YD3d#Mw{g-VaQKYWu3H;c*>p`4! zt z+8+Kcm36i&1=?X?yVpittkhM9+@bC+43~f}?FL`k#+=3Us<7i`$wGg=*UI2YANlH~vZye__h0~D{*3ZL~hz*2s}imJWFFRs~c%jsszsT=?#kMKfUg(u7g zu01%@PvZgr06>TeX6Z~e^~@d^9SQ(oGjci1tmsy_mxh2Tt+D^J4gkQm-|Lo^IYmkQ V>)=WObwf%%{A&Vmx61!B{ts@oN@)N9 literal 0 HcmV?d00001 diff --git a/public/project pics/emailsorter.webp b/public/project pics/emailsorter.webp new file mode 100644 index 0000000000000000000000000000000000000000..0fe0adf7825be81f2da880af0c0f1fa644b9fb78 GIT binary patch literal 10834 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 literal 0 HcmV?d00001 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 (