Files
kolaytercih/src/components/animasyonlu-mark.tsx
bilalgursen ab0b3a047f
All checks were successful
Deploy / deploy (push) Successful in 7m52s
Piksel harita ve logo animasyonu iyileştirmeleri
Şehir/Türkiye piksel haritası görünüme girince oynar; animasyonlu-mark,
console-asr, use-pixel-heat ve cta-sira-form güncellendi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 14:09:44 +03:00

239 lines
8.0 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 { cn } from "@/lib/utils";
// public/kolay-tercih-mark.svg'nin inline, animasyonlu karşılığı.
// İki 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.
// - "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).
//
// 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];
// 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;
/** 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}%`;
}
/** 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];
}
function donguyuBaslat(
el: HTMLElement,
i: number,
varyant: "rota" | "nabiz",
): 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 secenek = {
duration: ROTA_SURE,
delay: i * 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: KONUM_ZAMANLARI,
easing: YOL,
},
secenek,
),
el.animate(
{
scale: ROTA_NABIZ_SCALE.map(String),
opacity: ROTA_NABIZ_OPACITY,
offset: 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.
*/
export function AnimasyonluMark({
className,
sadeceHover = false,
varyant = "rota",
renk = "marka",
}: {
className?: string;
sadeceHover?: boolean;
varyant?: "rota" | "nabiz";
/** "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);
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[] = [];
let istekli = false;
const baslat = () => {
if (istekli) return;
istekli = true;
if (azHareket.matches) return;
// Kareler hâlâ yerine dönüyorsa (hızlı çık-gir) dönüşün bitmesi
// beklenir; döngü hep yerinden başlar, sıçrama olmaz.
Promise.all(donus.map((a) => a.finished))
.then(() => {
if (!istekli || dongu.length > 0) return;
dongu = kareler.flatMap((el, i) => donguyuBaslat(el, i, varyant));
})
.catch(() => {});
};
const durdur = () => {
istekli = false;
if (dongu.length === 0) return;
// Kareler o an neredeyse oradan yerlerine süzülür.
const anlik = kareler.map((el) => {
const cs = getComputedStyle(el);
return { translate: cs.translate, scale: cs.scale, opacity: cs.opacity };
});
dongu.forEach((a) => a.cancel());
dongu = [];
donus = kareler.map((el, i) =>
el.animate(
[
anlik[i],
{
translate: yer(KARELER[i].baslangic, KARELER[i].boyut),
scale: "1",
opacity: 1,
},
],
{ duration: DONUS_SURE, easing: DONUS },
),
);
};
if (!sadeceHover) {
baslat();
return () => {
dongu.forEach((a) => a.cancel());
};
}
// 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 hedef = kok.closest(".group") ?? kok;
hedef.addEventListener("mouseenter", baslat);
hedef.addEventListener("mouseleave", durdur);
return () => {
hedef.removeEventListener("mouseenter", baslat);
hedef.removeEventListener("mouseleave", durdur);
[...dongu, ...donus].forEach((a) => a.cancel());
};
}, [sadeceHover, varyant]);
return (
<span
ref={kokRef}
aria-hidden="true"
className={cn("relative block shrink-0", className)}
>
{KARELER.map((k) => (
<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),
}}
/>
))}
</span>
);
}