Files
kolaytercih/src/components/animasyonlu-mark.tsx
bilalgursen 1b51903e71 feat(tema): sayfa geçişi kısa ve hafif (600 ms), logo dönüşümü geçiş bitince oynar; DESIGN §4.22 logo morph
- Sıra: önce sayfa geçişi biter, kısa nefes (80 ms), sonra logo güneşe/aya
  dönüşür; üst üste binmez. Geçiş iptal olsa da logo doğru biçime dönüşür.
- View Transitions olmayan tarayıcıda da logo dönüşür.
- DESIGN.md §4.22: logo biçim dönüşümü tasarım diline eklendi (biçim tablosu,
  koreografi, kurallar); §6 hareket ve §7 Yap/Yapma satırları.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:48:41 +03:00

456 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 <rect> değil HTML <span>, 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<HTMLSpanElement>(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 <html> 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 (
<span
ref={kokRef}
aria-hidden="true"
className={cn(
"relative block shrink-0",
varyant !== "rota" && "isolate",
className,
)}
>
{KARELER.map((k, i) => (
<span
key={k.renk}
className={cn(
"absolute top-0 left-0 block",
renk === "turuncu" && "bg-orange-500",
)}
style={{
width: `${(k.boyut / KUTU) * 100}%`,
height: `${(k.boyut / KUTU) * 100}%`,
borderRadius: `${(13 / k.boyut) * 100}%`,
backgroundColor: renk === "marka" ? k.renk : undefined,
translate: yer(k.baslangic, k.boyut),
// Zincirde yürüyen kare hep hedefindekinin üstüne konar: sırası
// önce gelen üstte durur (mavi > şeftali > turuncu). Avatar nabız
// ile zincir arasında gidip geldiği için sıra nabızda da aynıdır
// (orada kareler üst üste gelmez); header'daki rota'ya dokunulmaz.
zIndex: varyant !== "rota" ? KARELER.length - i : undefined,
}}
/>
))}
</span>
);
}