Akademik göstergeler çekmecesine açılış koreografisi
Some checks failed
Deploy / deploy (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-04 13:56:42 +03:00
parent ae08de42c5
commit 9c02b810e7
2 changed files with 210 additions and 10 deletions

View File

@@ -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({
<DrawerDescription className="mt-1 px-10 text-center text-sm text-slate-500">
{universite}
</DrawerDescription>
<div className="mt-5">{children}</div>
<AcilisSahnesi>{children}</AcilisSahnesi>
</div>
</DrawerContent>
</Drawer>
);
}
// 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<HTMLDivElement>(null);
const azHareket = useReducedMotion();
useLayoutEffect(() => {
const kap = kapRef.current;
if (!kap) return;
const sahne = kap.querySelector<HTMLElement>("[data-sahne]");
const kareler = Array.from(
kap.querySelectorAll<HTMLElement>("[data-kare]"),
);
const sonra = Array.from(kap.querySelectorAll<HTMLElement>("[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 (
<div ref={kapRef} className="mt-5">
{children}
</div>
);
}