Piksel harita ve logo animasyonu iyileştirmeleri
All checks were successful
Deploy / deploy (push) Successful in 7m52s

Ş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>
This commit is contained in:
bilalgursen
2026-10-04 14:00:34 +03:00
parent 9c02b810e7
commit ab0b3a047f
7 changed files with 329 additions and 180 deletions

View File

@@ -1,10 +1,9 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
// public/kolay-tercih-mark.svg'nin inline, animasyonlu karşılığı (Motion).
// 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 →
@@ -14,9 +13,14 @@ import { cn } from "@/lib/utils";
// - "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).
//
// Not: motion.rect'te `x`/`y` transform değeridir (attribute değil) — kareler
// attribute yerine translate ile konumlanır; scale, transform-box: fill-box
// sayesinde karenin kendi merkezinden uygulanır.
// 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],
@@ -32,10 +36,11 @@ const KARELER = [
// 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 = 3.6;
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.
const ROTA_GECIKME_ADIMI = 0.2;
// 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 = [
@@ -46,20 +51,77 @@ 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 = 1.8;
const NABIZ_GECIKMELER = [0, 0.6, 1.2] as const;
const SINE = [0.37, 0, 0.63, 1] as const;
const NABIZ_SURE = 1800;
const NABIZ_GECIKMELER = [0, 600, 1200] as const;
/** Kare i için durak koordinatları: kendi yeri → sıradaki → ondan sonraki →
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, eksen: 0 | 1): number[] {
const [p0, p1, p2] = [0, 1, 2].map(
(adim) => POZISYONLAR[(baslangic + adim) % 3][eksen],
);
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
@@ -78,116 +140,99 @@ export function AnimasyonluMark({
* (orange-500) — ör. danışman panel başlığı. */
renk?: "marka" | "turuncu";
}) {
const [hover, setHover] = useState(false);
const svgRef = useRef<SVGSVGElement>(null);
const azHareket = useReducedMotion();
const aktif = !azHareket && (!sadeceHover || hover);
const kokRef = useRef<HTMLSpanElement>(null);
// 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 svg'ye düşer.
useEffect(() => {
if (!sadeceHover) return;
const hedef = svgRef.current?.closest(".group") ?? svgRef.current;
if (!hedef) return;
const ac = () => setHover(true);
const kapat = () => setHover(false);
hedef.addEventListener("mouseenter", ac);
hedef.addEventListener("mouseleave", kapat);
return () => {
hedef.removeEventListener("mouseenter", ac);
hedef.removeEventListener("mouseleave", kapat);
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(() => {});
};
}, [sadeceHover]);
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 (
<svg
ref={svgRef}
viewBox="0 0 180 180"
<span
ref={kokRef}
aria-hidden="true"
focusable="false"
className={cn("shrink-0", className)}
className={cn("relative block shrink-0", className)}
>
{KARELER.map((k, i) => {
const [px, py] = POZISYONLAR[k.baslangic];
const duruk = { x: px, y: py, scale: 1, opacity: 1 };
const animate = !aktif
? duruk
: varyant === "rota"
? {
x: rota(k.baslangic, 0),
y: rota(k.baslangic, 1),
scale: ROTA_NABIZ_SCALE,
opacity: ROTA_NABIZ_OPACITY,
}
: {
x: px,
y: py,
scale: [1, 0.82, 1],
opacity: [1, 0.55, 1],
};
// Kademeli kalkış: delay yalnızca ilk turdan önce uygulanır, sonraki
// turlarda faz farkı kendiliğinden korunur.
const rotaGecikme = i * ROTA_GECIKME_ADIMI;
const konumGecis = {
duration: ROTA_SURE,
times: KONUM_ZAMANLARI,
ease: "easeInOut" as const,
repeat: Infinity,
delay: rotaGecikme,
};
const rotaNabizGecis = {
duration: ROTA_SURE,
times: ROTA_NABIZ_ZAMANLARI,
ease: "easeInOut" as const,
repeat: Infinity,
delay: rotaGecikme,
};
const nabizGecis = {
duration: NABIZ_SURE,
times: [0, 0.5, 1],
ease: SINE as unknown as [number, number, number, number],
repeat: Infinity,
delay: NABIZ_GECIKMELER[i],
};
const transition = !aktif
? { duration: 0.4, ease: "easeOut" as const }
: varyant === "rota"
? {
x: konumGecis,
y: konumGecis,
scale: rotaNabizGecis,
opacity: rotaNabizGecis,
}
: {
x: { duration: 0 },
y: { duration: 0 },
scale: nabizGecis,
opacity: nabizGecis,
};
return (
<motion.rect
key={k.renk}
width={k.boyut}
height={k.boyut}
rx={13}
fill={k.renk}
className={renk === "turuncu" ? "fill-orange-500" : undefined}
animate={animate}
transition={transition}
style={
{
transformBox: "fill-box",
transformOrigin: "center",
} as CSSProperties
}
/>
);
})}
</svg>
{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>
);
}