"use client"; import { useEffect, useRef } from "react"; import { TEMA_GECIS_OLAYI } from "@/lib/tema-gecisi"; import { cn } from "@/lib/utils"; // public/kolay-tercih-mark.svg'nin inline, animasyonlu karşılığı. // Üç varyant: // // - "rota" (varsayılan): her kare sıradakinin pozisyonuna süzülür (mavi → // şeftalinin yeri, şeftali → turuncunun, turuncu → mavinin), üç adımda // herkes başlangıç formuna döner. Uçuş sırasında kare hafifçe küçülüp // solar, varışta toparlanır. Site header'daki logo hover'da bunu oynatır. // - "zincir": aynı rota, ama kareler aynı anda değil sırayla yürür — bir kare // hedefindeki karenin üzerine konarken alttaki yola çıkar, üçüncü kare boş // kalan yere oturur ve zincir baştan başlar. Danışman avatarı yalnız // danışman veriye bakarken (araç çalışırken) bunu oynatır. // - "nabiz": kareler yerlerinden ayrılmaz; 120° faz farkıyla dalga gibi // küçülüp büyür (danışman panel başlığının sakin, sürekli hâli; danışman // avatarı da cevap beklenirken böyle atar). // // Performans notu: kareler SVG değil HTML , animasyon da Web // Animations API ile yalnızca `translate` / `scale` / `opacity` üzerinden // yürür. Bu üçü compositor'da (GPU) oynar: ana iş parçacığı meşgulken // (hidrasyon, sayfa geçişi, kaydırma) bile kare düşmez ve hover React'i // yeniden render etmez. SVG iç elemanlarının transform'u her karede yeniden // boyanır; o yüzden buraya geri dönme. const KUTU = 180; // kaynak SVG'nin viewBox kenarı const POZISYONLAR = [ [27, 56], [91, 29], [91, 95], ] as const; const KARELER = [ { boyut: 57, renk: "#3b82f6", baslangic: 0 }, { boyut: 56, renk: "#fdc7a7", baslangic: 1 }, { boyut: 56, renk: "#ff5a00", baslangic: 2 }, ] as const; // Rota: tam tur 3.6s — 3 etap × (%25 yol = 0.9s + %8.3 varışta soluklanma). // 0.9s'lik yolculuk, nabız varyantının küçül-büyü temposuyla aynı hızda. const ROTA_SURE = 3600; // Kalkışlar kademeli: kareler eş zamanlı değil, danışman başlığındaki gibi // sırayla (mavi → şeftali → turuncu) yola çıkıp küçülür. Gecikme yalnızca ilk // turdan önce uygulanır, sonraki turlarda faz farkı kendiliğinden korunur. const ROTA_GECIKME_ADIMI = 200; const KONUM_ZAMANLARI = [0, 0.25, 1 / 3, 0.583, 2 / 3, 0.917, 1]; // Nabız (rota içi): her yolculuğun ortasında dibe vurur, varışta tam form. const ROTA_NABIZ_ZAMANLARI = [ 0, 0.125, 0.25, 1 / 3, 0.458, 0.583, 2 / 3, 0.792, 0.917, 1, ]; const ROTA_NABIZ_SCALE = [1, 0.84, 1, 1, 0.84, 1, 1, 0.84, 1, 1]; const ROTA_NABIZ_OPACITY = [1, 0.6, 1, 1, 0.6, 1, 1, 0.6, 1, 1]; // Zincir: iki kalkış arası 450 ms, tek yolculuk 600 ms. Sıradaki kare, gelen // kare yolun ~%94'ündeyken (üzerine konmuşken) kalkar; son 150 ms'de biri // inerken diğeri hızlanır, akış hiç durmaz. Her kare turda 3 yolculuk yapar. const ZINCIR_ADIM = 450; const ZINCIR_YOL = 600; const ZINCIR_SURE = ZINCIR_ADIM * 9; const ZINCIR_YOL_PAYI = ZINCIR_YOL / ZINCIR_SURE; const ZINCIR_KONUM_ZAMANLARI = [ ...[0, 1, 2].flatMap((etap) => [etap / 3, etap / 3 + ZINCIR_YOL_PAYI]), 1, ]; const ZINCIR_NABIZ_ZAMANLARI = [ ...[0, 1, 2].flatMap((etap) => [ etap / 3, etap / 3 + ZINCIR_YOL_PAYI / 2, etap / 3 + ZINCIR_YOL_PAYI, ]), 1, ]; // Yerinde nabız: kesintisiz tam-sinüs (dinlenme yok), delay'ler döngüyü üçe // böler — biri büyürken sıradaki küçülür, hiçbir kare beklemez. const NABIZ_SURE = 1800; const NABIZ_GECIKMELER = [0, 600, 1200] as const; const SINE = "cubic-bezier(0.37, 0, 0.63, 1)"; // Yolculuk: yumuşak kalkış, uzun ve yumuşak iniş (easeInOutCubic). const YOL = "cubic-bezier(0.65, 0, 0.35, 1)"; // Hover bitince yerine oturma (easeOutQuint): hızlı toparlanır, sarsmadan durur. const DONUS = "cubic-bezier(0.22, 1, 0.36, 1)"; const DONUS_SURE = 500; // `oynat` kapanınca yerine oturma: hover'daki kadar aceleci değil — duran kare // yumuşak kalkar, uzun ve yumuşak iner. const YERLES = "cubic-bezier(0.4, 0, 0.2, 1)"; const YERLES_SURE = 600; /** Karenin `translate` değeri: yüzdeler karenin kendi boyutuna göredir, bu * yüzden işaret hangi boyutta çizilirse çizilsin aynı oran korunur. */ function yer(pozisyon: number, boyut: number): string { const [x, y] = POZISYONLAR[pozisyon % 3]; return `${(x / boyut) * 100}% ${(y / boyut) * 100}%`; } /** Karenin merkezini 180'lik kutuda (cx, cy) noktasına taşıyan `translate`. */ function merkezde(cx: number, cy: number, boyut: number): string { return `${((cx - boyut / 2) / boyut) * 100}% ${((cy - boyut / 2) / boyut) * 100}%`; } // Tema geçişi biçimi (DESIGN.md §2.3): sayfa geçişi bitince kareler // neye geçildiğini gösterir, kısa bir an durur ve yeniden logoya toplanır. // - Koyuya: şeftali kare hilale, mavi kare hilalin kucağındaki yıldıza; // turuncu söner. // - Açığa: turuncu kare güneşe, şeftali kare arkasındaki haleye; mavi söner. // Toplam süre ve duraklar: %30'da biçim tamam, %70'e kadar durur, sonra döner. const SEKIL_SURE = 1700; const SEKIL_DURAKLAR = [0, 0.3, 0.7, 1]; type SekilHedefi = { translate: string; scale: string; opacity: number }; // Ay kompozisyonu kutunun merkezine (90) göre küçültülür ve logonun alanına // (x 27–151) oturacak kadar kaydırılır. const AY_OLCEK = 0.82; const AY_KAYMA_X = 8; function ay(nokta: number, kayma = AY_KAYMA_X): number { return 90 + (nokta - 90) * AY_OLCEK + kayma; } /** Kare `i` için hedef biçim. Kareler sırayla mavi, şeftali, turuncu. */ function sekilHedefi(i: number, koyu: boolean): SekilHedefi { const b = KARELER[i].boyut; if (koyu) { return [ // Biçim tema-ikonu.tsx'teki ay ikonuyla aynı: gövde r74 @ (76,92), // tek yıldız r13 @ (108,79) — hilalin iki sivri ucunun tam ortası (iki // dairenin kesişim kirişinin orta noktası). Turuncu kare yıldızın // yerinde söner: güneşte mavi, ayda turuncu sahneden çekilir. `ay` // kompozisyonu logonun kendi alanına sığdırır. { translate: merkezde(ay(108), ay(79, 0), b), scale: String((26 * AY_OLCEK) / b), opacity: 1 }, // yıldız { translate: merkezde(ay(76), ay(92, 0), b), scale: String((148 * AY_OLCEK) / b), opacity: 1 }, // hilal { translate: merkezde(ay(108), ay(79, 0), b), scale: "0.2", opacity: 0 }, // söner ][i]; } return [ { translate: merkezde(90, 90, b), scale: "0.3", opacity: 0 }, // söner { translate: merkezde(90, 90, b), scale: "2.15", opacity: 0.45 }, // hale { translate: merkezde(90, 90, b), scale: "1.1", opacity: 1 }, // güneş ][i]; } /** Kare için durak koordinatları: kendi yeri → sıradaki → ondan sonraki → * kendi yeri. Duraklarda bekleme için her hedef iki kez tekrarlanır * (KONUM_ZAMANLARI ile hizalı). */ function rota(baslangic: number, boyut: number): string[] { const [p0, p1, p2] = [0, 1, 2].map((adim) => yer(baslangic + adim, boyut)); return [p0, p1, p1, p2, p2, p0, p0]; } type Varyant = "rota" | "zincir" | "nabiz"; function donguyuBaslat( el: HTMLElement, i: number, varyant: Varyant, ): Animation[] { const k = KARELER[i]; if (varyant === "nabiz") { return [ el.animate( { scale: ["1", "0.82", "1"], opacity: [1, 0.55, 1], easing: SINE }, { duration: NABIZ_SURE, delay: NABIZ_GECIKMELER[i], iterations: Infinity, }, ), ]; } const zincir = varyant === "zincir"; const secenek = { duration: zincir ? ZINCIR_SURE : ROTA_SURE, delay: i * (zincir ? ZINCIR_ADIM : ROTA_GECIKME_ADIMI), iterations: Infinity, }; // Konum ve nabız ayrı animasyonlar: konum etabı tek parça eğriyle akar, // ortasındaki küçülme onu ikiye bölüp duraksatmaz. return [ el.animate( { translate: rota(k.baslangic, k.boyut), offset: zincir ? ZINCIR_KONUM_ZAMANLARI : KONUM_ZAMANLARI, easing: YOL, }, secenek, ), el.animate( { scale: ROTA_NABIZ_SCALE.map(String), // Zincirde kare solmaz: başka bir karenin üzerine konar, yarı saydam // olursa alttaki renk içinden görünür. ...(zincir ? {} : { opacity: ROTA_NABIZ_OPACITY }), offset: zincir ? ZINCIR_NABIZ_ZAMANLARI : ROTA_NABIZ_ZAMANLARI, easing: SINE, }, secenek, ), ]; } /** * Animasyonlu KolayTercih işareti. Varsayılanda koreografi sürekli döner; * `sadeceHover` ile yalnızca işaretin üzerine gelindiğinde oynar (ör. site * header'daki logo linki) ve hover bitince kareler yumuşakça yerine oturur. * `oynat` ile döngü dışarıdan açılıp kapatılır, `varyant` da çalışırken * değişebilir (ör. danışman avatarı: cevap beklenirken nabız, veriye bakarken * zincir); her geçişte kareler önce yumuşakça yerine oturur, yeni döngü * yerinden başlar. */ export function AnimasyonluMark({ className, sadeceHover = false, oynat = true, varyant = "rota", renk = "marka", }: { className?: string; sadeceHover?: boolean; /** `sadeceHover` yokken döngü açık mı. `false` iken işaret durağandır. */ oynat?: boolean; varyant?: Varyant; /** "marka": logodaki üç renk; "turuncu": tüm kareler sitenin CTA turuncusu * (orange-500) — ör. danışman panel başlığı. */ renk?: "marka" | "turuncu"; }) { const kokRef = useRef(null); const kontrolRef = useRef<{ ayarla: (hedef: Varyant | null, cevik?: boolean) => void; temaSekli: (koyu: boolean) => void; } | null>(null); // Animasyonların sahibi: bileşen yaşadıkça bir kez kurulur. Prop değişimi // bunu söküp kurmaz (kareler sıçrardı); aşağıdaki efekt `ayarla` ile hedefi // bildirir. useEffect(() => { const kok = kokRef.current; if (!kok || typeof kok.animate !== "function") return; const azHareket = window.matchMedia("(prefers-reduced-motion: reduce)"); const kareler = Array.from(kok.children) as HTMLElement[]; let dongu: Animation[] = []; let donus: Animation[] = []; /** Tema geçişi biçimi (güneş/ay) animasyonları; bkz. `temaSekli`. */ let sekil: Animation[] = []; /** Şu an dönen döngü ve istenen döngü (`null`: durağan). */ let aktif: Varyant | null = null; let hedef: Varyant | null = null; // Döngü bir mikro görev sonra başlar; o arada efekt sökülmüşse (React // geliştirme modunda her efekti kur-sök-kur yapar) başlamamalı — yoksa // kimsenin durduramadığı sahipsiz bir döngü kalır. let sokuldu = false; /** Döngüyü keser; kareler o an neredeyse oradan yerlerine süzülür. */ const yerles = (cevik: boolean) => { const anlik = kareler.map((el) => { const cs = getComputedStyle(el); return { translate: cs.translate, scale: cs.scale, opacity: cs.opacity }; }); // Nabızda kareler zaten yerindedir, yalnız boyut toparlanır: kısa sürer. const sure = aktif === "nabiz" ? YERLES_SURE / 2 : YERLES_SURE; dongu.forEach((a) => a.cancel()); dongu = []; aktif = null; donus = kareler.map((el, i) => el.animate( [ anlik[i], { translate: yer(KARELER[i].baslangic, KARELER[i].boyut), scale: "1", opacity: 1, }, ], cevik ? { duration: DONUS_SURE, easing: DONUS } : { duration: sure, easing: YERLES }, ), ); }; /** `yeni` döngüsünü, süren dönüş ve tema biçimi bitince yerinden başlatır. */ const donguyuSiraya = (yeni: Varyant) => { // Kareler hâlâ yerine dönüyorsa (ya da güneş/ay oynuyorsa) bitmesi // beklenir; döngü hep yerinden başlar, sıçrama olmaz. Promise.all([...donus, ...sekil].map((a) => a.finished)) .then(() => { if (sokuldu || hedef !== yeni || aktif !== null) return; dongu = kareler.flatMap((el, i) => donguyuBaslat(el, i, yeni)); aktif = yeni; }) .catch(() => {}); }; const ayarla = (yeni: Varyant | null, cevik = false) => { if (yeni === hedef) return; hedef = yeni; if (aktif !== null) yerles(cevik); if (yeni === null || azHareket.matches) return; donguyuSiraya(yeni); }; /** * Tema geçişi biçimi: kareler güneşe ya da aya dönüşür, durur, logoya * döner. Hover döngüsünden önceliklidir: döngü oynuyorsa kesilir, kareler * o an neredeyse oradan biçime geçer; biçim bitince imleç hâlâ * üzerindeyse döngü yerinden yeniden başlar. Hareket azaltılmışsa çalışmaz. * Hilal, kareyi kesen ikinci bir şekille değil karenin kendi iç gölgesiyle * çizilir (zemin şeffaf, renk iç gölgede): arkadaki zemin ne olursa olsun * doğru görünür. */ const temaSekli = (koyu: boolean) => { if (azHareket.matches) return; // Karelerin o anki hâli: döngü ya da önceki biçim yarıdaysa oradan çıkılır. const anlik = kareler.map((el) => { const cs = getComputedStyle(el); return { translate: cs.translate, scale: cs.scale, opacity: cs.opacity, borderRadius: cs.borderRadius, }; }); [...dongu, ...donus, ...sekil].forEach((a) => a.cancel()); dongu = []; donus = []; aktif = null; sekil = kareler.flatMap((el, i) => { const k = KARELER[i]; const ev = { translate: yer(k.baslangic, k.boyut), scale: "1", opacity: 1, borderRadius: `${(13 / k.boyut) * 100}%`, }; const bicim = { ...sekilHedefi(i, koyu), borderRadius: "50%" }; const secenek = { duration: SEKIL_SURE }; const animasyonlar = [ el.animate( [ { ...anlik[i], easing: DONUS }, { ...bicim, easing: "linear" }, { ...bicim, easing: YERLES }, ev, ].map((kare, d) => ({ ...kare, offset: SEKIL_DURAKLAR[d] })), secenek, ), ]; // Hilal: yalnız şeftali kare, yalnız koyuya geçerken. if (koyu && i === 1) { const renk = getComputedStyle(el).backgroundColor; const w = el.offsetWidth; const dolu = `inset 0 0 0 ${w}px ${renk}`; // tema-ikonu.tsx'teki hilalle aynı kesim: gövde r74'ten, (+40,-16) // kaydırılmış r66 daire çıkar — sırtı sol altta. // İç gölgede: kaydırma = kesim dairesinin kayması, yayılma = iki // yarıçapın farkı (hepsi gövde çapı 148'e oranla). const hilal = `inset ${(40 / 148) * w}px ${(-16 / 148) * w}px 0 ${(8 / 148) * w}px ${renk}`; animasyonlar.push( el.animate( [ { boxShadow: dolu, backgroundColor: "transparent", easing: DONUS }, { boxShadow: hilal, backgroundColor: "transparent", easing: "linear" }, { boxShadow: hilal, backgroundColor: "transparent", easing: YERLES }, { boxShadow: dolu, backgroundColor: "transparent" }, ].map((kare, d) => ({ ...kare, offset: SEKIL_DURAKLAR[d] })), secenek, ), ); } return animasyonlar; }); // İmleç hâlâ üzerindeyse hover döngüsü biçimden sonra sürer. if (hedef !== null) donguyuSiraya(hedef); }; const kontrol = { ayarla, temaSekli }; kontrolRef.current = kontrol; return () => { sokuldu = true; if (kontrolRef.current === kontrol) kontrolRef.current = null; [...dongu, ...donus, ...sekil].forEach((a) => a.cancel()); }; }, []); // Üstteki efektten sonra koşar (aynı bileşende efektler sırayla çalışır), // o yüzden kontrolRef burada doludur. useEffect(() => { const kok = kokRef.current; if (!kok) return; if (!sadeceHover) { kontrolRef.current?.ayarla(oynat ? varyant : null); return; } kontrolRef.current?.ayarla(null, true); // Hover hedefi işaretin kendisi değil, en yakın `group` ebeveyni (site // header'da logo + "KolayTercih" yazısını saran Link): yazının üzerine // gelindiğinde de animasyon başlar. `group`'suz kullanımda köke düşer. const hoverHedefi = kok.closest(".group") ?? kok; const gir = () => kontrolRef.current?.ayarla(varyant, true); const cik = () => kontrolRef.current?.ayarla(null, true); hoverHedefi.addEventListener("mouseenter", gir); hoverHedefi.addEventListener("mouseleave", cik); // Tema geçişi bitince (lib/tema-gecisi.ts) kareler yeni temanın // biçimine dönüşür: koyuya geçişte ay ve yıldızlar, açığa geçişte güneş. // Olay anında yeni temanın sınıfını taşır. const temaGecti = () => kontrolRef.current?.temaSekli( document.documentElement.classList.contains("dark"), ); window.addEventListener(TEMA_GECIS_OLAYI, temaGecti); return () => { hoverHedefi.removeEventListener("mouseenter", gir); hoverHedefi.removeEventListener("mouseleave", cik); window.removeEventListener(TEMA_GECIS_OLAYI, temaGecti); }; }, [oynat, sadeceHover, varyant]); return (