diff --git a/docs/tasarim/DESIGN.md b/docs/tasarim/DESIGN.md index 3817f7b..b1f6396 100644 --- a/docs/tasarim/DESIGN.md +++ b/docs/tasarim/DESIGN.md @@ -668,8 +668,9 @@ sihirbaz modalı içindir; yeni bir ayrıntı için `Dialog` açılmaz. tek çekmece açar; veri `/api/program-ayrinti`. Sıra: başlık = program adı, bağlam = üniversite · il · fakülte; **künye hapları** (§4.12 ölçüsü, link değil: süre + tür, dil, öğretim türü (örgün değilse), burs/ücret, akreditasyon, baraj, öğretim üyesi sayısı, - yalnız kız/erkek koşulu, kampüs ilçesi — yalnız verisi olanlar); "Kontenjan türleri (yıl)" `dl` satırları - (yalnız >0 olanlar; özel kontenjanın ayrı taban sırasının elimizde olmadığı notu); + yalnız kız/erkek koşulu, kampüs ilçesi — yalnız verisi olanlar); "Kontenjan türleri (yıl)" satırları + (yalnız >0 olanlar; özel kontenjanın ayrı taban sırasının elimizde olmadığı notu; güncel + düzen aşağıda, 11 Eki 2026); "Son yerleşenin netleri" (eski tablo + OBP katsayısı satırı; 0,18 = meslek lisesi ek puanı notu); "Yerleşme koşulları (N)" yerel `details` ile kapalı başlar; "Mezun istihdamı" ve "Üniversite" tek paragraf; en altta kaynak satırı `border-t border-slate-100 @@ -693,6 +694,37 @@ sihirbaz modalı içindir; yeni bir ayrıntı için `Dialog` açılmaz. göstergeler çekmecesi (§4.9) aynı bileşenin kartlı modunu kullanır (her açılışta oynar). Gövde kaydırılınca içerik tutamaç ve çarpının altına maskesiz girmesin diye `AltSayfaGovde` üst kenarında 20 px saydam → dolu `mask-image` var (§4.10; tüm çekmeceler). +- **11 Eki 2026 — kontenjan türleri (açık panelin içi): önce bütün, sonra parçalar:** Bilal: "daha anlaşılabilir + bir UI." Eski hâl (uzun açıklama paragrafı + iki sütunlu `dl`, "70 kontenjan · 72 yerleşen") + kimin hangi yere girebildiğini söylemiyordu. Yeni sıra: tek cümle toplam (`text-base + text-slate-600`, sayı `font-semibold text-slate-900`: "Toplam 81 kişilik yer açıldı: 70 herkese + açık, 11 yalnız şartı taşıyan adaylara") → oran çubuğu (`flex h-3 gap-0.5 rounded-full`, + dilim genişliği kontenjanla orantılı, `min-w-1.5`; genel `bg-primary`, özel `bg-slate-300`, + `aria-hidden`) → iki grup: "Herkese açık" ve "Yalnız şartı taşıyan adaylara" (grup etiketi + `text-sm font-semibold text-slate-500` + çubukla aynı renkte `size-2.5` nokta). Satır tek + sütun, `border-b border-slate-100 py-3`: solda ad (`text-base font-semibold`) ve altında + kimin girebildiği (`KONTENJAN_TURU_KIME`, `src/lib/ozel-durum.ts`); sağda sayı (`font-heading + text-xl font-bold` + `kişi` birimi) ve altında "N yerleşti · M boş kaldı". Renk yalnız + açık/şartlı ayrımını taşır, türün adı her satırda yazılıdır. Altta not: özel taban sırası + bizde yok; genelde yerleşen kontenjanı aşıyorsa aktarım cümlesi eklenir. Yalnız genel + kontenjan varsa çubuk ve grup yok, tek satır. Kart yok kuralı duruyor. +- **11 Eki 2026 — üç bölüm dönüşen gösterge karesi:** Bilal: "Kontenjan türleri, son + yerleşenin netleri, mezun istihdamı akademik göstergelerdeki gibi kutu olsun; tıklanınca o + kutu morph olarak genişlesin ve içeriğin tam hâli gözüksün." Bu üç bölüm artık künye + haplarının altında §4.11 gösterge kareleridir (`BolumKutulari`; sınıflar §4.11 ile birebir, + masonry: sol sütun Kontenjan `bg-orange-500 min-h-40` + İstihdam `bg-indigo-500 min-h-28`, + sağ sütun `mt-12` Son yerleşen `bg-primary min-h-40`). Kare bir düğmedir: etiket + sayı + + vurgu satırı, sağ üstte `Maximize2 size-4 text-white/80`, `active:scale-[0.97]`. Dokununca + kare **tam genişlikte panele dönüşür** (`motion` `layoutId`, yay `YAY.donusum`; köşe 24 px + stilde ki ölçeklenirken bozulmasın): panel `rounded-3xl bg-slate-50 p-4 sm:p-5`, kenarlıksız; + karenin rengi panelin üstünde `SURE.standart`'ta söner, içerik 0,14 s gecikmeyle belirir. + Panel başlığı: karenin renginde `size-3 rounded-[4px]` kare + `font-heading text-base + font-bold` başlık + §4.3 çarpısı ("küçült"). Aynı anda tek panel açık; kalan iki kare panelin + altında yan yana durur, dokununca yer değiştirir. Odak dönüşen öğeyle gider (açılınca çarpı, + kapanınca kare). "Hareketi azalt"ta dönüşüm yok (`MotionConfig reducedMotion="user"`). Verisi + olmayan bölümün karesi çizilmez. 10 Ekim'deki "bu çekmecede kart yok" kuralı bu üç bölüm için + kalktı; koşullar, üniversite ve kaynak satırı kartsız kalır. Açılış sahnesi değişmedi (logo → + başlık); kareler tek `data-sonra` bloğu olarak netleşir. - **10 Eki 2026 — büyük logo, logo → başlık (morph):** Bilal: "logo küçücük kalmış; logolar küçülürken başlıktaki yazıya morph olsun." Logo kabı `size-44` (akademik göstergelerdeki logoyla aynı ölçek). Kareler sönmek yerine küçülerek (scale 0.2) başlık yazısının üç eşit @@ -890,43 +922,58 @@ dilindedir, burada kullanılmaz). Kaynak Arc UI "Card stack". kullanacağız". Dosyalar duruyor; şu an hiçbir sayfada çizilmiyor. ### 4.28 Veri akışı — `VeriAkisi`, `src/features/pazarlama/components/veri-akisi.tsx` -Ana sayfa "Nasıl çalışır" bölümünde, üç adımın (§4.32) altında: ortada KolayTercih işareti, -çevresinde altı girdi; her girdiden merkeze **düz çizgi** iner, üzerinde piksel kare akar. **Solda -öğrencinin üç seçimi** (turuncu ikon ve kare), **sağda YÖK Atlas'ın üç verisi** (mavi ikon ve -kare); ayrımı etiket ve yan taşır, renk eşlik eder (§2.2). Akış CSS'tir (`kt-akis`, globals.css), -yalnız ekrandayken oynar; hareketi azalt'ta kareler durur. Esin React Bits "Center Flow"; kod -bizim — parlama ve bulanıklık yok, akış içeri doğru. Yeni bir girdi eklenirse sihirbaz/havuz -kurallarıyla aynı olmalı (`/meraklisina` şemasıyla çelişmez). -- **Çizgi düz, karolar beyaz** (Bilal, 7 Eki 2026: "aradaki yerler solid çizgi olsun, etrafındaki - kartların bg'si ve görünümü daha iyi olabilir"). Çizgi `var(--color-slate-300)`, 1,5 px - (`vectorEffect="non-scaling-stroke"`), karonun merkezinden işaretin merkezine çekilir ve - karoların **altında** kalır; kesik/noktalı çizgi yazılmaz. Girdi karosu - `size-14 rounded-2xl border border-slate-200 bg-card` + `size-6` ikon; ortadaki karo - `size-24 rounded-3xl border border-slate-200 bg-card`. Renkli soluk zemin (`bg-orange-50/60`, - `bg-primary/10`) kaldırıldı: karo dolu olmalı ki çizgi içinden görünmesin. Gölge yok (§6). +Ana sayfa "Nasıl çalışır" bölümünde, üç adımın (§4.32) altında. **Üstte öğrencinin üç seçimi** +(turuncu ikon ve kayıt), **altta YÖK Atlas'ın üç verisi** (mavi ikon ve kayıt), **ortada +danışman**: "DanışmanlaKonuş" çekirdeği. Her kutudan çekirdeğe kıvrımlı bir yol iner, üzerinde +kayıt akar; ayrımı etiket ve sıra taşır, renk eşlik eder (§2.2). Akış CSS'tir (`kt-akis-*`, +globals.css), yalnız ekrandayken oynar; hareketi azalt'ta her yolda bir ham kayıt durağan durur. +Yerleşim ve kaydın yaşam döngüsü Arc UI "Data flow"dan (Bilal, 11 Eki 2026: önce "logoya kareler +gelen kısım bunun gibi gözükmeli", sonra "ortadaki kısmı dikdörtgen formuna sokup DanışmanlaKonuş +yazsak, kutular attığım örnekteki gibi"). Arc'ın bileşeni Pro'dur, kaynağı alınmadı (§11.3 +md. 7); önizlemedeki davranış DOM kaydıyla ölçülüp bizim dille yeniden yazıldı — parlama ve +bulanıklık yok. Yeni bir girdi eklenirse sihirbaz/havuz kurallarıyla aynı olmalı +(`/meraklisina` şemasıyla çelişmez). Eski altıgen yerleşim (ortada kare logo karosu, çevresinde +altı karo, düz çizgiler) kaldırıldı. +- **Kutu:** `h-20 w-[30%] rounded-2xl border border-slate-200 bg-card`, ikon üstte (`size-5`, + yanın renginde), etiket altta (`text-xs font-semibold leading-tight text-balance + text-slate-700`, en çok iki satır). Etiket kutunun **içindedir**; dışarıda yüzen etiket yok. + Üç kutu sütunları kabın %16,7 / %50 / %83,3'ünde. Gölge yok (§6). +- **Çekirdek:** `h-16 rounded-2xl border border-slate-200 bg-card`, içinde danışman panelinin + başlığı (`DanismanBaslik` deseni, `sohbet-client.tsx`): turuncu `AnimasyonluMark` + (`varyant="nabiz" renk="turuncu"`, `size-7`) + `font-bricolage text-xl tracking-tighter` + "Danışmanla" (`font-light`) "Konuş" (`font-semibold`), boşluksuz. İşaret ≈3 sn nabız atar, + sonra girdilerden çıkana dönüşür (liste → trend → sohbet, `bicimler`, §4.34) ve toplanır. +- **Yol:** `var(--color-slate-300)`, 1,5 px, kesiksiz; iki ucu dikey çıkan S kıvrımı + (`C x0 ym, x1 ym`). Yollar çekirdeğin kenarına ortadan 4'er birim açılarak varır. Kutudan + çıktığı yerde 7 px'lik **port** halkası durur. +- **Kayıt ham çıkar, işlenmiş varır.** Ömrü 1,4 sn: porttan küçük ve soluk doğar (ölçek + 0,6 → 1), arkasında iki küçük noktadan bir **iz** taşır; yol boyunca **içi boş nokta**dır (ham + girdi: `bg-card` dolgu, 1,5 px renkli çerçeve). Çekirdeğe yaklaşınca hafifçe şişer (1,2), + **dolar ve piksel kareye döner** (işlenmiş), kenarda sönerek içeri alınır. O an **çekirdeğin + çerçevesi** kaydın renginde yanar, genişleyip söner; kalkışta da kutunun çerçevesi ve portu + kısa bir an yanar (tepe %50). Ham ile işlenmiş yalnız renkle değil **biçimle** ayrılır. +- **Ritim:** 6 sn'lik döngüde altı kayıt, 1 sn arayla, bir üstten bir alttan kalkar; yol 1,2 sn + sürdüğü için biri varırken sıradaki yola çıkmıştır, akış hiç durmaz. +- **Kayıt eğriyi JS'siz izler.** Bu S kıvrımında parametre zamanla doğrusal ilerlerken x tam + olarak `cubic-bezier(1/3, 0, 2/3, 1)`, y tam olarak `cubic-bezier(1/3, .5, 2/3, .5)` izler; + kayıt iç içe iki öğedir (dıştaki yatay, içteki dikey kayar) ve çizginin tam üstünde yürür. Bu + iki eğri hareket zevki değil yolun geometrisidir (`--akis-x`, `--akis-y`); `hareket.ts`'e + girmez, yol değişirse onlar da değişir. İz noktaları aynı iki kaymayı 70 ve 140 ms gecikmeyle + yapar. Yatay eksen kabın yüzdesidir (kap `@container`, x'ler `cqw`), dikey eksen rem; ölçüm + ya da `ResizeObserver` yok. Eğri öğenin `animation` kısayolundadır: tarayıcı `@keyframes` + içindeki `animation-timing-function: var(--…)` değerini çözmez, doğrusala düşer. - **Kartsız, zeminsiz; bilgiler iki yanında, onlar da kartsız** (Bilal, 7 Eki 2026: "kartın dışında, arka plansız, sağında solunda da alttaki bilgiler olacak şekilde"; aynı gün: "hero'daki gibi ferah"). Geniş ekranda (lg+) üç sütun (`lg:grid-cols-[minmax(0,1fr)_22rem_minmax(0,1fr)]`, xl'de orta 24rem, dikeyde ortalı): **solda dürüstlük notu** — `SectionEyebrow` + `h3` (`font-heading text-2xl font-bold text-balance`, ikinci yarısı hero başlığındaki vurgu: `font-bricolage font-bold tracking-tighter text-primary italic`) + `text-base` paragraf, sola - yaslı, `max-w-xs`; **ortada animasyon**; **sağda üç sayı** alt alta, sağa yaslı (YÖK Atlas - girdilerinin yanında — sayılar o verinin sayılarıdır). Sayı §1.1: `font-heading text-4xl + yaslı, `max-w-xs`; **ortada animasyon**; **sağda üç sayı** alt alta, sağa yaslı (sayılar + alt sıradaki YÖK Atlas verisinin sayılarıdır). Sayı §1.1: `font-heading text-4xl font-bold leading-none tracking-tight text-primary sm:text-5xl`, birim ("yıl") §3'teki gibi `font-sans text-base font-semibold text-slate-400`, etiket `text-sm font-semibold text-slate-500`. Çerçeve, zemin, ayırıcı çizgi ve sayının yanında piksel süs **yok**. lg altında tek sütun, her şey ortalı: animasyon → sayılar (sm'de üçü yan yana) → dürüstlük notu. -- **Ortadaki işaret danışmandır: nabız atar, sonra çıktıya dönüşür** (Bilal, 11 Eki 2026: - "logo morph ile ikonlara dönüşsün; belirli süre DanışmanlaKonuş'un animasyonu oynayıp morph - olsun, şu ankini kaldır"). İşaret ≈3 sn DanışmanlaKonuş başlığındaki gibi yerinde nabız atar - (`varyant="nabiz"`: danışman düşünüyor), sonra üç kare girdilerden çıkan şeye dönüşür, ≈1,3 sn - durur ve logoya toplanır (toplam 2,5 sn); nabız yerinden sürer, sıra bir sonraki biçime - geçer. Sıra paketin verdiğidir: **liste** (deste) → **trend** (yükselen üç çubuk) → **sohbet** - (iki balon). Biçimler §4.34'tekilerle aynı dilde, üç kareden ve logonun üç renginden - (`AnimasyonluMark` `bicimler`). Yalnız ekrandayken oynar; ekrandan çıkınca kareler yerine - oturur. Hareketi azalt'ta durağan logo. Eski "rota" (kareler birbirinin yerine süzülür) bu - karodan kaldırıldı; ondan önceki nabız + tüm karoyu döndüren hâl (`kt-akis-donus`) de yanlıştı - (Bilal, 7 Eki 2026). ### 4.29 Program duvarı — `ProgramDuvari`, `src/features/pazarlama/components/program-duvari.tsx` **Şu an sayfada kullanılmıyor:** ana sayfa "Sorun" bölümünden 7 Eki 2026'da çıkarıldı (Bilal: diff --git a/src/app/globals.css b/src/app/globals.css index 325d84f..0ff7188 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -455,39 +455,277 @@ } -/* Veri akışı (veri-akisi.tsx, DESIGN.md §4.28): kare, çizgi boyunca karodan - merkeze akar. Uç noktalar satır içi --x1/--y1/--x2/--y2 ile gelir (SVG - içinde px = viewBox birimi). Yalnız ekrandayken oynar. */ -@keyframes kt-akis { +/* Veri akışı (veri-akisi.tsx, DESIGN.md §4.28). Üstte üç kutu, ortada + çekirdek, altta üç kutu; yollar SVG, kayıtlar HTML (yalnız translate / + scale / opacity oynar). + + Geometri: yatay eksen kabın eninin yüzdesi (kap `@container`, x'ler cqw), + dikey eksen rem: kutular 5rem, çekirdek 10,5–14,5rem arası, sahne 25rem. + Kaydın iki ucu satır içi gelir (--x0/--y0 port, --x1/--y1 çekirdek kenarı). + + Eğri: yol, iki ucu dikey çıkan S kıvrımıdır — C(x0 ym, x1 ym), ym orta + yükseklik. Bu eğride parametre zamanla doğrusal ilerlerken x tam olarak + cubic-bezier(1/3, 0, 2/3, 1), y tam olarak cubic-bezier(1/3, .5, 2/3, .5) + izler. Kayıt bu yüzden iç içe iki öğedir: dıştaki yatay, içteki dikey + kayar; ikisi birlikte çizginin tam üstünde yürür, ölçüm ya da JS gerekmez. + Bu iki eğri hareket zevki değil yolun geometrisidir (--akis-x / --akis-y); + yol değişirse onlar da değişir. İz, aynı iki kaymayı --ek kadar gecikmeyle + yapan iki küçük noktadır; böylece o da çizginin üstünde kalır. + + Döngü 6 sn, altı kayıt 1 sn arayla (--g satır içi gecikme). Bir kaydın ömrü: + 0 → 20 % yol (1,2 sn) + 0 → 4 % kalkış: porttan küçük ve soluk çıkar (ölçek 0,6 → 1) + 13 → 17 % işlenir: içi boş nokta şişer (1 → 1,2), dolar ve piksel kareye döner + 15 → 19 % iz çekirdek kenarına varmadan söner + 20 → 23 % kayıt kenarda sönerek içeri alınır + 20 → 32 % çekirdek nabzı: çerçeve kaydın renginde yanar, genişleyip söner + 0 → 10 % kaynak vurgusu: kutunun çerçevesi ve port kısa bir an yanar + Eğri öğenin `animation` kısayolundadır, keyframe'in içinde değil: tarayıcı + `@keyframes` içindeki `animation-timing-function: var(--…)` değerini çözmez + (doğrusala düşer). Kısayoldaki eğri her durak aralığına ayrı ayrı uygulanır. + Yalnız ekrandayken oynar (data-oynuyor). Hareketi azalt'ta her yolda bir + ham kayıt durağan durur. */ +.kt-akis-sahne { + height: 25rem; + --akis-x: cubic-bezier(0.3333, 0, 0.6667, 1); + --akis-y: cubic-bezier(0.3333, 0.5, 0.6667, 0.5); +} + +.kt-akis-kayit-kap { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + pointer-events: none; +} + +/* Port: yolun kutudan çıktığı nokta; kayıt buradan doğar. */ +.kt-akis-port { + position: absolute; + top: -3.5px; + left: -3.5px; + width: 7px; + height: 7px; + border: 1px solid var(--color-slate-300); + border-radius: 9999px; + background: var(--card); + translate: var(--x0) var(--y0); +} + +.kt-akis-port > .kt-akis-vurgu { + position: absolute; + inset: 1px; + border-radius: inherit; + background: var(--renk); +} + +.kt-akis-x, +.kt-akis-y { + position: absolute; + top: 0; + left: 0; +} + +.kt-akis-x { + translate: var(--x0) 0; + animation: kt-akis-x 6s var(--akis-x) calc(var(--g, 0s) + var(--ek, 0s)) infinite; +} + +.kt-akis-y { + translate: 0 var(--y0); + animation: kt-akis-y 6s var(--akis-y) calc(var(--g, 0s) + var(--ek, 0s)) infinite; +} + +/* İz noktası: kayıttan küçük, dolu ve soluk. */ +.kt-akis-iz { + position: absolute; + top: -2px; + left: -2px; + width: 4px; + height: 4px; + border-radius: 9999px; + background: var(--renk); + opacity: 0; + animation: kt-akis-iz 6s var(--egri-standart) calc(var(--g, 0s) + var(--ek, 0s)) infinite; +} + +/* Ham kayıt: içi boş nokta. */ +.kt-akis-kare { + position: absolute; + top: -5px; + left: -5px; + box-sizing: border-box; + width: 10px; + height: 10px; + border: 1.5px solid var(--renk); + border-radius: 50%; + background: var(--card); + opacity: 0; + scale: 0.6; + animation: kt-akis-kare 6s var(--egri-standart) var(--g, 0s) infinite; +} + +/* İşlenmiş kayıt: aynı kutunun dolusu, üstüne solarak gelir. */ +.kt-akis-dolgu { + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--renk); + opacity: 0; + animation: kt-akis-dolgu 6s var(--egri-standart) var(--g, 0s) infinite; +} + +.kt-akis-nabiz, +.kt-akis-vurgu { + border-color: var(--renk); + opacity: 0; +} + +.kt-akis-nabiz { + animation: kt-akis-nabiz 6s var(--egri-guclu-cikis) var(--g, 0s) infinite; +} + +.kt-akis-vurgu { + animation: kt-akis-vurgu 6s var(--egri-standart) var(--g, 0s) infinite; +} + +@keyframes kt-akis-x { 0% { - translate: var(--x1) var(--y1); + translate: var(--x0) 0; + } + 20%, + 100% { + translate: var(--x1) 0; + } +} + +@keyframes kt-akis-y { + 0% { + translate: 0 var(--y0); + } + 20%, + 100% { + translate: 0 var(--y1); + } +} + +@keyframes kt-akis-iz { + 0%, + 3% { opacity: 0; } - 12%, - 82% { - opacity: 1; + 7%, + 15% { + opacity: 0.4; } + 19%, 100% { - translate: var(--x2) var(--y2); opacity: 0; } } -.kt-akis-kare { - translate: var(--x1) var(--y1); - opacity: 0; - animation: kt-akis 2.4s cubic-bezier(0.45, 0, 0.55, 1) infinite; +@keyframes kt-akis-kare { + 0% { + scale: 0.6; + opacity: 0; + border-radius: 50%; + } + 4%, + 13% { + scale: 1; + opacity: 1; + border-radius: 50%; + } + 17% { + scale: 1.2; + opacity: 1; + border-radius: 25%; + } + 20% { + scale: 1; + opacity: 1; + border-radius: 25%; + } + 23%, + 100% { + scale: 0.8; + opacity: 0; + border-radius: 25%; + } +} + +@keyframes kt-akis-dolgu { + 0%, + 13% { + opacity: 0; + } + 17%, + 100% { + opacity: 1; + } +} + +/* Çekirdek geniş bir dikdörtgen: yatayda az, dikeyde çok büyür ki halka her + yanda aynı kadar (≈6 px) açılsın. */ +@keyframes kt-akis-nabiz { + 0%, + 19.9% { + scale: 1; + opacity: 0; + } + 20% { + scale: 1; + opacity: 0.5; + } + 32%, + 100% { + scale: 1.06 1.2; + opacity: 0; + } +} + +@keyframes kt-akis-vurgu { + 0% { + opacity: 0; + } + 2% { + opacity: 0.5; + } + 10%, + 100% { + opacity: 0; + } +} + +/* Oynatma durumu `animation` kısayolundan sonra gelmeli (kısayol sıfırlar). */ +:is(.kt-akis-x, .kt-akis-y, .kt-akis-iz, .kt-akis-kare, .kt-akis-dolgu, .kt-akis-nabiz, .kt-akis-vurgu) { animation-play-state: paused; } -[data-oynuyor] .kt-akis-kare { +[data-oynuyor] + :is(.kt-akis-x, .kt-akis-y, .kt-akis-iz, .kt-akis-kare, .kt-akis-dolgu, .kt-akis-nabiz, .kt-akis-vurgu) { animation-play-state: running; } @media (prefers-reduced-motion: reduce) { - .kt-akis-kare { + :is(.kt-akis-x, .kt-akis-y, .kt-akis-iz, .kt-akis-kare, .kt-akis-dolgu, .kt-akis-nabiz, .kt-akis-vurgu) { animation: none; } + + /* Durağan kare: yolun ortasında ham kayıt — bilgi eksilmez. */ + .kt-akis-x { + translate: calc((var(--x0) + var(--x1)) / 2) 0; + } + + .kt-akis-y { + translate: 0 calc((var(--y0) + var(--y1)) / 2); + } + + .kt-akis-kare { + opacity: 1; + scale: 1; + } } /* Tema geçişi (DESIGN.md §2.3, lib/tema-gecisi.ts): yeni tema header'daki diff --git a/src/features/katalog/components/program-ayrinti.tsx b/src/features/katalog/components/program-ayrinti.tsx index 92b1fc6..a39b0e5 100644 --- a/src/features/katalog/components/program-ayrinti.tsx +++ b/src/features/katalog/components/program-ayrinti.tsx @@ -7,7 +7,9 @@ // türleri, son yerleşenin netleri, yerleşme koşulları, TÜİK mezun istihdamı, // üniversite özeti. API'nin `gecmis` (yıllara göre sıra/puan/kontenjan) // alanı burada gösterilmez (Bilal, 7 Eki 2026: yıl tablosu kaldırıldı; -// backlog). Açılış: veri ilk gelince logo kareleri belirir, söner, içerik +// backlog). Kontenjan türleri, netler ve mezun istihdamı kapalıyken gösterge +// karesidir; dokununca kare panele dönüşüp bölümün tamamını açar +// (BolumKutulari; Bilal, 11 Eki 2026). Açılış: veri ilk gelince logo kareleri belirir, söner, içerik // bloklar hâlinde netleşir (AcilisSahnesi kartsız modu); veri önbellekteyken // (çekmece yeniden açıldı) sahne yok, içerik 0,25 s solmayla gelir. // Kadın-erkek dağılımı program düzeyinde bilinçli olarak yok: YÖK Atlas 2026 @@ -18,7 +20,8 @@ // değiştiği için takılmış görünüyordu (7 Eki 2026). import { SATIR_BASLIK, SATIR_DUGME_BOYU } from "@/components/liste-satiri"; -import { useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { LayoutGroup, MotionConfig, motion } from "motion/react"; import { BadgeCheck, Clock, @@ -27,17 +30,27 @@ import { Info, Languages, MapPin, + Maximize2, Monitor, Users, Wallet, X, } from "lucide-react"; import type { ProgramAyrintisi } from "@/app/api/program-ayrinti/route"; -import type { KontenjanTuruAnahtari, ProgramNetSatiri } from "@/types/yokatlas"; +import type { + KontenjanTuruAnahtari, + KontenjanTurleri, + ProgramNetSatiri, +} from "@/types/yokatlas"; import { olay } from "@/lib/analitik"; +import { SURE, YAY } from "@/lib/hareket"; // Yalnız saf modüller: bu bileşen istemcide çalışır, db'ye dokunan // program-ozellik.ts import EDİLMEZ (kadro toplamı API'den gelir) -import { CINSIYET_KOSULU_METNI, KONTENJAN_TURU_ADI } from "@/lib/ozel-durum"; +import { + CINSIYET_KOSULU_METNI, + KONTENJAN_TURU_ADI, + KONTENJAN_TURU_KIME, +} from "@/lib/ozel-durum"; import { trBaslikDuzeni } from "@/lib/slug"; import { UNITUR_ETIKET } from "@/lib/unitur"; import { cn } from "@/lib/utils"; @@ -252,18 +265,8 @@ function AyrintiIcerik({ durum, veri }: { durum: Durum; veri: ProgramAyrintisi | ))} - -
-

Son yerleşenin netleri

-

- Bölüme en düşük puanla yerleşen adayın sınav netleri (YÖK Atlas). -

-
- -
-
+ -

Kaynaklar: YÖK Atlas (yerleştirme, kontenjan, netler, koşullar; {SON_YIL}{" "} @@ -316,35 +319,364 @@ function Kunye({ veri }: { veri: ProgramAyrintisi }) { ); } -function KontenjanTurleriBolumu({ veri }: { veri: ProgramAyrintisi }) { - const k = veri.kontenjanTurleri; - if (!k) return null; - const satirlar = KONTENJAN_TURU_SIRASI.filter((t) => (k[t].kontenjan ?? 0) > 0); - if (satirlar.length === 0) return null; - const ozelVar = satirlar.some((t) => t !== "genel"); +/** "2 yerleşti · 1 boş kaldı" — yerleşen kontenjanı aşabilir (aktarım), o zaman yalnız sayı. */ +function yerlesenMetni(kontenjan: number, yerlesen: number | null): string { + if (yerlesen == null) return "Yerleşen verisi yok"; + if (yerlesen === 0) return "Yerleşen olmadı"; + const bos = kontenjan - yerlesen; + return bos > 0 ? `${sayi(yerlesen)} yerleşti · ${sayi(bos)} boş kaldı` : `${sayi(yerlesen)} yerleşti`; +} + +function KontenjanSatiri({ tur, k }: { tur: KontenjanTuruAnahtari; k: KontenjanTurleri }) { + const kontenjan = k[tur].kontenjan ?? 0; return ( -

-

Kontenjan türleri ({k.yil})

-

- {ozelVar - ? "ÖSYM okul birincileri, şehit ve gazi yakınları, depremzedeler ve 34 yaş üstü kadınlar için ayrı kontenjan ayırır; bu kontenjanlar yalnız o durumdaki adaylara açıktır ve her birinin taban sırası ayrı yayımlanır — o sıra burada yok." - : "Bu programda yalnız genel kontenjan var."} +

  • +
    +

    {KONTENJAN_TURU_ADI[tur]}

    +

    {KONTENJAN_TURU_KIME[tur]}

    +
    +
    +

    + {sayi(kontenjan)} + kişi +

    +

    + {yerlesenMetni(kontenjan, k[tur].yerlesen)} +

    +
    +
  • + ); +} + +const GRUP_ETIKET = "flex items-center gap-2 text-sm font-semibold text-slate-500"; + +/** Kontenjan kırılımının özeti: kutudaki sayı ve açık paneldeki satırlar aynı hesaptan. */ +function kontenjanOzeti(k: KontenjanTurleri) { + const satirlar = KONTENJAN_TURU_SIRASI.filter((t) => (k[t].kontenjan ?? 0) > 0); + const adet = (t: KontenjanTuruAnahtari) => k[t].kontenjan ?? 0; + const ozel = satirlar.filter((t) => t !== "genel"); + const genelVar = satirlar.includes("genel"); + const genel = genelVar ? adet("genel") : 0; + const ozelToplam = ozel.reduce((a, t) => a + adet(t), 0); + return { + satirlar, + ozel, + genelVar, + genel, + ozelToplam, + toplam: genel + ozelToplam, + // Dolmayan özel kontenjan genele aktarılır: genelde yerleşen kontenjanı aşar. + aktarim: genelVar && (k.genel.yerlesen ?? 0) > genel, + }; +} + +// Önce bütün (toplam + oran çubuğu), sonra parçalar: herkese açık yer ile +// yalnız şartı taşıyana açık yer iki ayrı grup. Çubukta yalnız bu ikili ayrım +// renklidir (mavi / nötr); türün adı her satırda yazılı, renk tek başına +// anlam taşımaz. +function KontenjanGovde({ k }: { k: KontenjanTurleri }) { + const o = kontenjanOzeti(k); + if (o.ozel.length === 0) { + return ( + <> +

    + Bu programda yalnız genel kontenjan var; özel durumlar için ayrı yer ayrılmamış. +

    +
      + +
    + + ); + } + return ( + <> +

    + Toplam {sayi(o.toplam)} kişilik yer + açıldı + {o.genelVar + ? `: ${sayi(o.genel)} herkese açık, ${sayi(o.ozelToplam)} yalnız şartı taşıyan adaylara.` + : "; tamamı yalnız şartı taşıyan adaylara."}

    -
    - {satirlar.map((t) => ( -
    -
    {KONTENJAN_TURU_ADI[t]}
    -
    - {sayi(k[t].kontenjan ?? 0)} - - {" "} - kontenjan · {k[t].yerlesen != null ? sayi(k[t].yerlesen) : "—"} yerleşen - -
    -
    +
    + {o.satirlar.map((t) => ( + ))} -
    -
    + + {o.genelVar ? ( + <> +

    + + Herkese açık +

    +
      + +
    + + ) : null} +

    + + Yalnız şartı taşıyan adaylara +

    +
      + {o.ozel.map((t) => ( + + ))} +
    +

    + Özel kontenjanların taban sırası ayrı yayımlanır; o sıra burada yok. + {o.aktarim + ? " Dolmayan özel kontenjan genele aktarılır; genel kontenjanda yerleşenin kontenjanı aşması bundandır." + : ""} +

    + + ); +} + +// Gösterge karesi (DESIGN.md §4.11) — akademik göstergelerdeki karelerle aynı +// sınıf seti; burada kare bir düğmedir. +const KARE = "flex flex-col items-center justify-center rounded-3xl px-3 text-white"; +const KARE_ETIKET = "text-sm font-bold text-white/90"; +const KARE_DEV_SAYI = "font-heading text-5xl font-bold leading-none tracking-tight"; +const KARE_ORTA_SAYI = "font-heading text-3xl font-bold leading-none"; +const KARE_VURGU = "text-base font-bold text-white/90"; +const KOSE = 24; // rounded-3xl; motion köşeyi ölçek sırasında düzeltsin diye stilde + +type KutuAnahtari = "kontenjan" | "netler" | "istihdam"; +type Kutu = { + anahtar: KutuAnahtari; + baslik: string; + /** Karenin zemini (§2 "Gösterge karesi"); anlam taşımaz, etiket yazılı */ + renk: string; + boy: string; + ozet: ReactNode; + icerik: ReactNode; +}; + +function kutulariKur(veri: ProgramAyrintisi): Kutu[] { + const kutular: Kutu[] = []; + const k = veri.kontenjanTurleri; + const o = k ? kontenjanOzeti(k) : null; + if (k && o && o.satirlar.length > 0) { + kutular.push({ + anahtar: "kontenjan", + baslik: `Kontenjan türleri (${k.yil})`, + renk: "bg-orange-500", + boy: "min-h-40 py-4", + ozet: ( + <> + Kontenjan + {sayi(o.toplam)} + + {o.ozel.length === 0 + ? "Tamamı genel" + : o.genelVar + ? `${sayi(o.genel)} genel · ${sayi(o.ozelToplam)} özel` + : "Tamamı özel"} + + + ), + icerik: , + }); + } + if (veri.netler.length > 0) { + const son = veri.netler.reduce((a, b) => (b.yil > a.yil ? b : a)); + kutular.push({ + anahtar: "netler", + baslik: "Son yerleşenin netleri", + renk: "bg-primary", + boy: "min-h-40 py-4", + ozet: + son.tabanPuan != null ? ( + <> + Son yerleşen + {ondalik(son.tabanPuan)} + Puan ve netler + + ) : ( + <> + {son.yil} + Son yerleşenin netleri + + ), + icerik: ( + <> +

    + Bölüme en düşük puanla yerleşen adayın sınav netleri (YÖK Atlas). +

    +
    + +
    + + ), + }); + } + const istihdamCumlesi = istihdamMetni(veri); + const v = veri.istihdam; + if (v && istihdamCumlesi) { + kutular.push({ + anahtar: "istihdam", + baslik: "Mezun istihdamı", + renk: "bg-indigo-500", + boy: "min-h-28 py-3", + ozet: + v.istihdam != null ? ( + <> + %{ondalik(v.istihdam)} + İşe yerleşen mezun + + ) : v.isBulmaAy != null ? ( + <> + {ondalik(v.isBulmaAy)} ay + İlk işi bulma + + ) : ( + <> + {v.kazanc} + Kazanç grubu + + ), + icerik:

    {istihdamCumlesi}

    , + }); + } + return kutular; +} + +/** + * Kontenjan türleri · Son yerleşenin netleri · Mezun istihdamı: kapalıyken + * gösterge kareleri (§4.11 masonry), dokununca kare tam genişlikte panele + * dönüşür ve bölümün tamamı açılır (Bilal, 11 Eki 2026). Aynı anda tek panel + * açıktır; öbür kareler panelin altında yan yana kalır ve dokununca yer + * değiştirir. Dönüşüm `layoutId` ile (kare → panel aynı öğe sayılır), yay + * `YAY.donusum`; "hareketi azalt"ta dönüşüm yok, içerik doğrudan değişir. + */ +function BolumKutulari({ veri }: { veri: ProgramAyrintisi }) { + const [acik, setAcik] = useState(null); + // Az önce kapanan kare: panelden küçülürken içi ezik görünmesin diye solarak gelir. + const [kapanan, setKapanan] = useState(null); + const kapatRef = useRef(null); + const kareRef = useRef>>({}); + const ilk = useRef(true); + + // Odak dönüşen öğeyle birlikte gider: açılınca çarpıya, kapanınca kareye. + useEffect(() => { + if (ilk.current) { + ilk.current = false; + return; + } + if (acik) kapatRef.current?.focus({ preventScroll: true }); + else if (kapanan) kareRef.current[kapanan]?.focus({ preventScroll: true }); + }, [acik, kapanan]); + + const kutular = kutulariKur(veri); + if (kutular.length === 0) return null; + const acikKutu = kutular.find((x) => x.anahtar === acik) ?? null; + const kapali = kutular.filter((x) => x.anahtar !== acik); + // Kapalıyken masonry (sol: 1. ve 3., sağ: 2., aşağı kaymış); panel açıkken + // kalan kareler yan yana. + const sol = kapali.filter((_, i) => i % 2 === 0); + const sag = kapali.filter((_, i) => i % 2 === 1); + + function ac(anahtar: KutuAnahtari) { + setKapanan(acik); + setAcik(anahtar); + } + function kapat() { + setKapanan(acik); + setAcik(null); + } + + const kare = (x: Kutu) => ( + { + kareRef.current[x.anahtar] = el; + }} + layoutId={`ayrinti-kutu-${x.anahtar}`} + type="button" + aria-expanded={false} + onClick={() => ac(x.anahtar)} + style={{ borderRadius: KOSE }} + className={cn( + KARE, + "relative w-full cursor-pointer transition-[scale] duration-150 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + x.renk, + x.boy, + )} + > + + {x.ozet} + + + — {x.baslik}, tamamını göster + + ); + + return ( + + +
    + {acikKutu ? ( + + {/* Karenin rengi panelin üstünde söner: renk de biçimle birlikte dönüşür. */} + + +
    +

    + + {acikKutu.baslik} +

    + +
    + {acikKutu.icerik} +
    +
    + ) : null} + {kapali.length > 0 ? ( +
    +
    {sol.map(kare)}
    + {sag.length > 0 ? ( +
    0 && "mt-12")}> + {sag.map(kare)} +
    + ) : null} +
    + ) : null} +
    +
    +
    ); } @@ -480,7 +812,8 @@ function Kosullar({ veri }: { veri: ProgramAyrintisi }) { ); } -function Istihdam({ veri }: { veri: ProgramAyrintisi }) { +/** Mezun istihdamı cümlesi (TÜİK); gösterecek değer yoksa null. */ +function istihdamMetni(veri: ProgramAyrintisi): string | null { const v = veri.istihdam; if (!v) return null; const parcalar = [ @@ -493,15 +826,8 @@ function Istihdam({ veri }: { veri: ProgramAyrintisi }) { const birlesik = parcalar.join(", "); const cumle = birlesik.charAt(0).toLocaleUpperCase("tr-TR") + birlesik.slice(1); const ort = v.ortalama.istihdam != null ? ` Türkiye ortalaması %${ondalik(v.ortalama.istihdam)}.` : ""; - return ( -
    -

    Mezun istihdamı

    -

    - {cumle} (TÜİK {v.yil}, ülke geneli, {v.duzey === "lisans" ? "lisans" : "ön lisans"}{" "} - mezunları; üniversiteye göre değişir, kırılım yok).{ort} -

    -
    - ); + const duzey = v.duzey === "lisans" ? "lisans" : "ön lisans"; + return `${cumle} (TÜİK ${v.yil}, ülke geneli, ${duzey} mezunları; üniversiteye göre değişir, kırılım yok).${ort}`; } function UniversiteOzeti({ veri }: { veri: ProgramAyrintisi }) { diff --git a/src/features/liste/components/tercih-haritasi.tsx b/src/features/liste/components/tercih-haritasi.tsx index 2d8e2e0..f5f7b5c 100644 --- a/src/features/liste/components/tercih-haritasi.tsx +++ b/src/features/liste/components/tercih-haritasi.tsx @@ -162,6 +162,15 @@ export function TercihHaritasi({ } }, []); + // Kenar eritme mask'ı yalnızca yakınlaşmış hâlde: tam görünümde Türkiye'nin + // uçları (Edirne, Hakkari) rampaya giriyor ve harita kırpılmış gibi + // okunuyordu (Bilal, 11 Eki 2026). Yakınlaşırken hemen açılır (ilk karede + // kenarlar boş, geçiş görünmez); uzaklaşırken animasyon bitene kadar kalır + // ki komşu iller sert kenarla kırpılmış görünmesin. + // Uzaklaşma animasyonu sürerken true; rAF içinde yazılır (effect'te senkron + // setState değil), bitince düşer. + const [uzaklasiyor, setUzaklasiyor] = useState(false); + const animateTo = useCallback((hedef: ViewBox) => { cancelAnimationFrame(animRef.current); const kaynak = { ...vbRef.current }; @@ -175,6 +184,8 @@ export function TercihHaritasi({ // sf'e bağlı ölçüler tek render'da nihai değere oturur; rAF içinde // (asenkron) çağrıldığı için effect'te senkron setState sayılmaz. setVb(hedef); + // Mount'taki tam görünüm → tam görünüm çağrısında açılmasın + setUzaklasiyor(hedef === TAM_GORUNUM && kaynak.w !== hedef.w); } const t = Math.min(1, (now - t0) / SURE); const k = ease(t); @@ -190,6 +201,7 @@ export function TercihHaritasi({ `${sonraki.x} ${sonraki.y} ${sonraki.w} ${sonraki.h}`, ); if (t < 1) animRef.current = requestAnimationFrame(adim); + else setUzaklasiyor(false); }; animRef.current = requestAnimationFrame(adim); }, []); @@ -239,6 +251,7 @@ export function TercihHaritasi({ // Ekran boyutu sabit kalsın diye SVG içi ölçüler zoom oranıyla çarpılır const sf = vb.w / HARITA_GENISLIK; const zoomda = Boolean(seciliIl); + const kenarErit = zoomda || uzaklasiyor; const seciliPinler = useMemo( () => (seciliIl ? (ilPinleri.get(seciliIl) ?? []) : []), [ilPinleri, seciliIl], @@ -297,6 +310,28 @@ export function TercihHaritasi({ return (
    + {/* Harita arka plandır, kesilmez (DESIGN.md §4.17): il'e yakınlaşınca + viewBox komşu illeri dikdörtgenle kırpıyor ve sert kenarlı bir + kanvas görünüyordu (Bilal, 11 Eki 2026: "canvas çok keskin"). + Şehir/üniversite sayfalarındaki piksel haritayla aynı iki katlı + mask: dış kat üst-alt, iç kat sol-sağ kenarı eritir; hiçbir + kenarda yarım kesilmiş il kalmaz. Yalnız yakınlaşmışken (kenarErit); + tam görünümde mask yok, ülke uçları solmaz. "Türkiye" düğmesi + mask'ın dışında, erimez. */} +
    +
    +
    +
    {zoomda ? (