From 9c02b810e7b90d3e9accfd22321ea4d37f267f31 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Sun, 4 Oct 2026 13:56:42 +0300 Subject: [PATCH] =?UTF-8?q?Akademik=20g=C3=B6stergeler=20=C3=A7ekmecesine?= =?UTF-8?q?=20a=C3=A7=C4=B1l=C4=B1=C5=9F=20koreografisi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kareler önce logonun üç karesi gibi belirir, sonra yerlerine büyür; içerik satır satır netleşir. Hareketi azalt'ta yalnız kısa belirme. Co-Authored-By: Claude Fable 5.1 --- .../katalog/components/akademik-ayrinti.tsx | 194 +++++++++++++++++- .../katalog/components/universite-icerik.tsx | 26 ++- 2 files changed, 210 insertions(+), 10 deletions(-) diff --git a/src/features/katalog/components/akademik-ayrinti.tsx b/src/features/katalog/components/akademik-ayrinti.tsx index 37d7636..ecba94a 100644 --- a/src/features/katalog/components/akademik-ayrinti.tsx +++ b/src/features/katalog/components/akademik-ayrinti.tsx @@ -1,6 +1,7 @@ "use client"; -import type { ReactNode } from "react"; +import { useLayoutEffect, useRef, type ReactNode } from "react"; +import { animate, useReducedMotion } from "motion/react"; import { ChevronRight, GraduationCap, X } from "lucide-react"; import { Drawer, @@ -17,7 +18,8 @@ import { * kartlarda, ayrıntı bir dokunuş derinde. İkinci satır içeride ne olduğunu * önceden söyler. Ayrıntı alttan açılan çekmecede; çekmece ve kapatma * çarpısı cta-sira-form.tsx'teki çekmeceyle birebir aynı sınıf setinde. - * Kartlar sunucuda üretilip `children` olarak gelir. + * Kartlar sunucuda üretilip `children` olarak gelir; açılış koreografisini + * AcilisSahnesi oynatır. */ export function AkademikAyrinti({ universite, @@ -64,9 +66,195 @@ export function AkademikAyrinti({ {universite} -
{children}
+ {children} ); } + +// Logodaki üç kare (public/kolay-tercih-mark.svg, 180'lik viewBox): solda +// mavi, sağ üstte şeftali, sağ altta turuncu. animasyonlu-mark.tsx ile aynı. +const LOGO = { + sol: { x: 27, y: 56, boyut: 57, renk: "#3b82f6" }, + "sag-ust": { x: 91, y: 29, boyut: 56, renk: "#fdc7a7" }, + "sag-alt": { x: 91, y: 95, boyut: 56, renk: "#ff5a00" }, +} as const; +const LOGO_RX = 13; +const SLOT_SIRASI = ["sol", "sag-ust", "sag-alt"] as const; + +// Saniye. Kare: belir (logo) → bekle → yerine büyü; içerik kare oturunca. +const BASLANGIC = 0.1; +const SLOT_ADIMI = 0.08; +const KARE_SURE = 1.15; +const KARE_ZAMANLARI = [0, 0.24, 0.52, 1]; +const ICERIK_BASI = 0.95; +const GUCLU_CIKIS = [0.23, 1, 0.32, 1] as const; +const YERLESME = [0.32, 0.72, 0, 1] as const; + +/** + * Çekmece açılınca oynayan sahne: renkli kareler önce ortada logonun üç + * karesi gibi belirir, sonra kendi yerlerine ve boylarına büyür (renk logo + * renginden kendi rengine döner), en son içleri satır satır bulanıklıktan + * netleşerek dolar; ardından alttaki not, buton ve kaynaklar gelir. + * + * Sunucu işaretlemesiyle sözleşme (universite-icerik.tsx, AkademikGostergeler): + * `data-sahne` kareleri saran `relative` kap, `data-kare="sol|sag-ust|sag-alt"` + * karenin logodaki yeri (aynı yeri paylaşan kareler üst üste başlar, sonra + * ayrışır), `data-sonra` karelerden sonra gelen bloklar. + * + * Kareler yalnız transform ile oynar (yerleşim ölçülür, bozulmaz); içerik o + * sırada görünmez olduğu için kare ölçeklenirken yazı ezilmez. "Hareketi + * azalt"ta logo ve büyüme yok, yalnız kısa belirme (DESIGN.md §6). + */ +function AcilisSahnesi({ children }: { children: ReactNode }) { + const kapRef = useRef(null); + const azHareket = useReducedMotion(); + + useLayoutEffect(() => { + const kap = kapRef.current; + if (!kap) return; + const sahne = kap.querySelector("[data-sahne]"); + const kareler = Array.from( + kap.querySelectorAll("[data-kare]"), + ); + const sonra = Array.from(kap.querySelectorAll("[data-sonra]")); + const kontroller: { stop: () => void }[] = []; + const sayaclar: number[] = []; + const dokunulan: HTMLElement[] = []; + + if (azHareket || !sahne || kareler.length === 0) { + kontroller.push( + animate(kap, { opacity: [0, 1] }, { duration: 0.25, ease: "easeOut" }), + ); + return () => kontroller.forEach((k) => k.stop()); + } + + // Bulanıklıktan netleşerek, hafifçe aşağıdan gelen satır. + const netlestir = (el: HTMLElement, gecikme: number) => { + el.style.opacity = "0"; + dokunulan.push(el); + const k = animate( + el, + { + opacity: [0, 1], + filter: ["blur(10px)", "blur(0px)"], + y: [10, 0], + }, + { duration: 0.5, delay: gecikme, ease: GUCLU_CIKIS }, + ); + k.then(() => { + el.style.filter = ""; + }); + kontroller.push(k); + }; + + // Logo, kare alanının ortasında; 180'lik viewBox'ın ekrandaki boyu. + const W = sahne.offsetWidth; + const H = sahne.offsetHeight; + const logoBoy = Math.min(W * 0.5, 200); + const olcek = logoBoy / 180; + const logoX = (W - logoBoy) / 2; + const logoY = (H - logoBoy) / 2; + + let sonKare = 0; + kareler.forEach((el, i) => { + const slot = LOGO[el.dataset.kare as keyof typeof LOGO] + ? el.dataset.kare! + : "sol"; + const logo = LOGO[slot as keyof typeof LOGO]; + // offset*: transform'dan etkilenmez (çekmece o sırada kayıyor). + let sol = 0; + let ust = 0; + for ( + let d: HTMLElement | null = el; + d && d !== sahne; + d = d.offsetParent as HTMLElement | null + ) { + sol += d.offsetLeft; + ust += d.offsetTop; + } + const w = el.offsetWidth; + const h = el.offsetHeight; + const boy = logo.boyut * olcek; + const sx = boy / w; + const sy = boy / h; + const dx = logoX + logo.x * olcek + boy / 2 - (sol + w / 2); + const dy = logoY + logo.y * olcek + boy / 2 - (ust + h / 2); + // Ölçek köşeyi de küçültür; logodaki köşe oranı için ters ölçekli elips. + const rx = (LOGO_RX * olcek) / sx; + const ry = (LOGO_RX * olcek) / sy; + const logoKose = `${rx}px / ${ry}px`; + const kose = getComputedStyle(el).borderTopLeftRadius; + const gecikme = + BASLANGIC + + SLOT_SIRASI.indexOf(slot as (typeof SLOT_SIRASI)[number]) * SLOT_ADIMI; + sonKare = Math.max(sonKare, gecikme); + + el.style.opacity = "0"; + el.style.backgroundColor = logo.renk; + dokunulan.push(el); + const k = animate( + el, + { + opacity: [0, 1, 1, 1], + x: [dx, dx, dx, 0], + y: [dy, dy, dy, 0], + scaleX: [sx * 0.4, sx, sx, 1], + scaleY: [sy * 0.4, sy, sy, 1], + borderRadius: [logoKose, logoKose, logoKose, `${kose} / ${kose}`], + }, + { + duration: KARE_SURE, + delay: gecikme, + times: KARE_ZAMANLARI, + ease: [GUCLU_CIKIS, "linear", YERLESME], + }, + ); + k.then(() => { + el.style.borderRadius = ""; + }); + kontroller.push(k); + // Yerine büyürken logo renginden karenin kendi rengine (sınıftaki renk). + sayaclar.push( + window.setTimeout( + () => { + el.style.transition = "background-color 450ms ease-out"; + el.style.backgroundColor = ""; + }, + (gecikme + KARE_SURE * KARE_ZAMANLARI[2]) * 1000, + ), + ); + + Array.from(el.children).forEach((satir, j) => + netlestir( + satir as HTMLElement, + gecikme + ICERIK_BASI + i * 0.06 + j * 0.05, + ), + ); + }); + + sonra.forEach((el, i) => + netlestir(el, sonKare + ICERIK_BASI + 0.3 + i * 0.08), + ); + + return () => { + kontroller.forEach((k) => k.stop()); + sayaclar.forEach((s) => window.clearTimeout(s)); + for (const el of dokunulan) { + el.style.opacity = ""; + el.style.filter = ""; + el.style.transform = ""; + el.style.transition = ""; + el.style.backgroundColor = ""; + el.style.borderRadius = ""; + } + }; + }, [azHareket]); + + return ( +
+ {children} +
+ ); +} diff --git a/src/features/katalog/components/universite-icerik.tsx b/src/features/katalog/components/universite-icerik.tsx index 9920328..7d620b4 100644 --- a/src/features/katalog/components/universite-icerik.tsx +++ b/src/features/katalog/components/universite-icerik.tsx @@ -190,6 +190,8 @@ function AkademikGostergeler({ uni }: { uni: UniDetay }) { // sütun, sağdaki aşağı kaymış, kare boyları farklı — ne tepeler ne // bitişler hizalı (masonry). Renk sempati katar, anlamı etiket taşır; // yazı beyaz (turuncu/mavi zeminde sitenin CTA'larıyla aynı ikili). + // data-* işaretleri açılış koreografisinin sözleşmesi (akademik-ayrinti.tsx, + // AcilisSahnesi). const kare = "flex flex-col items-center justify-center rounded-3xl px-3 text-white"; const etiket = "text-sm font-bold text-white/90"; @@ -198,7 +200,7 @@ function AkademikGostergeler({ uni }: { uni: UniDetay }) { const orta = "font-heading text-3xl font-bold leading-none"; const siraKaresi = urap ? ( -
+
Türkiye sırası
{sayi(urap.sira)}. @@ -225,7 +227,7 @@ function AkademikGostergeler({ uni }: { uni: UniDetay }) { // yapılmaz. Bu yüzden ayrı kare değil, öğrenci karesinin alt satırı. const kadin = i ? kadinYuzdesi(i) : null; const ogrenciKaresi = oranVar ? ( -
+
{sayi(i.ogrenci)}
Örgün öğrenci
{kadin != null ? ( @@ -236,7 +238,7 @@ function AkademikGostergeler({ uni }: { uni: UniDetay }) {
) : null; const oranKaresi = oranVar ? ( -
+
Hoca başına öğrenci
{sayi(oran)}
@@ -245,7 +247,7 @@ function AkademikGostergeler({ uni }: { uni: UniDetay }) {
) : null; const uyeKaresi = oranVar ? ( -
+
{sayi(i.ogretimUyesi)}
Öğretim üyesi
@@ -254,7 +256,10 @@ function AkademikGostergeler({ uni }: { uni: UniDetay }) { return (
-
+
{siraKaresi} {ogrenciKaresi} @@ -269,7 +274,10 @@ function AkademikGostergeler({ uni }: { uni: UniDetay }) { {urap ? ( <> -

+

Türkiye sırası araştırma performansını ölçer; eğitim kalitesini ya da iş bulma oranını ölçmez.

@@ -277,6 +285,7 @@ function AkademikGostergeler({ uni }: { uni: UniDetay }) { asChild size="lg" variant="outline" + data-sonra className="mt-4 cursor-pointer px-6 transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]" > @@ -287,7 +296,10 @@ function AkademikGostergeler({ uni }: { uni: UniDetay }) { ) : null} -
+
{urap ? : null}
{urap ? (