Files
kolaytercih/src/lib/katalog.ts
bilalgursen 259534fa9c feat(katalog+tasarim): şehir adları her yerde link; piksel yüzeylere beyaz ton
- ilSayfaHref: üniversite sayfası il hapı, /universiteler listesi, bölüm /
  sonuç / listem / tadımlık satırlarındaki il metası şehir sayfasına gider.
- Şehir kümeleri tüm (üniversite, il) çiftlerinden: DB'deki her il değerinin
  sayfası var (ODTÜ Ankara + KKTC); dev'de eski globalThis önbelleğine dayanıklı.
- PikselZemin ton="acik", CtaBandi/CtaSiraForm ton="beyaz": /universiteler
  altı ve /meraklisina kapanışı beyaz, diğer bantlar mavi (dönüşümlü).
- ilUlkeHaritasi: il değerinden ülke haritası + şehir konumu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:56:11 +03:00

517 lines
16 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.
import "server-only";
import {
getDb,
programSatiri,
SELECT_COLS,
EFEKTIF_SIRA,
type ProgramHamSatiri,
} from "./db";
import type { Program } from "@/types/yokatlas";
import { kategoriBul, type Kategori } from "./kategoriler";
import { uniAdiNormalize } from "./harita";
import { bolumBazAdi, turkishSlugify, trBaslikDuzeni } from "./slug";
import { urapSirasiBul, type UrapSira } from "./urap-siralama";
import { uniIstatistikBul, type UniIstatistik } from "./yok-istatistik";
/**
* Programatik SEO sayfalarının veri katmanı (/bolum, /universite, hub'lar).
*
* Slug haritaları modül-scope'ta lazy kurulur: DB readonly açıldığı için
* slugs tablosu yazılamaz; SELECT DISTINCT (1.5K satır) süreç başına bir kez
* <10ms sürer. Harita değerleri HAM isim/universite dizeleridir — sorgular
* `WHERE isim IN (?, ...)` ile birebir eşleşip index'e vurur (WHERE içinde
* TRIM/fonksiyon kullanmak index'i öldürür).
*/
// ---------- Bölüm tarafı ----------
export type BolumOzet = {
slug: string;
ad: string;
programSayisi: number;
kategoriler: Kategori[];
lisans: boolean;
onlisans: boolean;
/** MIN(efektif sıra) — en iyi (en küçük) taban sıralaması */
enIyiSira: number | null;
};
export type BolumStats = {
toplam: number;
devlet: number;
vakif: number;
/** unitur boş/NULL olan satırlar — vakıf SAYILMAZ, ayrı gösterilir */
bilinmeyenTur: number;
minSira: number | null;
maxSira: number | null;
minPuan: number | null;
maxPuan: number | null;
ilSayisi: number;
};
export type BolumDetay = BolumOzet & {
lisansProgramlari: Program[];
onlisansProgramlari: Program[];
stats: BolumStats;
};
type BolumKayit = { slug: string; ad: string; hamIsimler: string[] };
const globalKatalog = globalThis as unknown as {
bolumMap?: Map<string, BolumKayit>;
bolumOzetler?: BolumOzet[];
uniMap?: Map<string, UniKayit>;
uniOzetler?: UniOzet[];
iller?: IlOzet[];
ilMap?: Map<string, IlOzet>;
/** slug → (il → o ildeki program sayısı); getAllUniversiteler doldurur */
uniIlAdetleri?: Map<string, Map<string, number>>;
};
function bolumMapGetir(): Map<string, BolumKayit> {
if (!globalKatalog.bolumMap) {
const rows = getDb()
.prepare("SELECT DISTINCT isim FROM programs")
.all() as { isim: string }[];
const map = new Map<string, BolumKayit>();
for (const { isim } of rows) {
const baz = bolumBazAdi(isim);
if (!baz) continue;
const slug = turkishSlugify(baz);
if (!slug) continue;
const kayit = map.get(slug);
if (kayit) kayit.hamIsimler.push(isim);
else map.set(slug, { slug, ad: baz, hamIsimler: [isim] });
}
if (map.size === 0) {
// generateStaticParams boş dizi dönerse build hatası — boş DB'yi erken yakala
throw new Error("katalog: programs tablosu boş görünüyor");
}
globalKatalog.bolumMap = map;
}
return globalKatalog.bolumMap;
}
export function getAllBolumler(): BolumOzet[] {
if (!globalKatalog.bolumOzetler) {
const map = bolumMapGetir();
// Tek taramada tüm bölümlerin özeti: 23K satır, süreç başına bir kez
const rows = getDb()
.prepare(
`SELECT isim, onlisans, ${EFEKTIF_SIRA} AS efektif FROM programs`,
)
.all() as { isim: string; onlisans: number; efektif: number | null }[];
const ozet = new Map<string, BolumOzet>();
const slugByIsim = new Map<string, string>();
for (const kayit of map.values()) {
for (const ham of kayit.hamIsimler) slugByIsim.set(ham, kayit.slug);
ozet.set(kayit.slug, {
slug: kayit.slug,
ad: kayit.ad,
programSayisi: 0,
kategoriler: kategoriBul(kayit.ad),
lisans: false,
onlisans: false,
enIyiSira: null,
});
}
for (const row of rows) {
const slug = slugByIsim.get(row.isim);
if (!slug) continue;
const b = ozet.get(slug)!;
b.programSayisi += 1;
if (row.onlisans === 1) b.onlisans = true;
else b.lisans = true;
if (row.efektif != null && (b.enIyiSira == null || row.efektif < b.enIyiSira)) {
b.enIyiSira = row.efektif;
}
}
globalKatalog.bolumOzetler = [...ozet.values()].sort((a, b) =>
a.ad.localeCompare(b.ad, "tr-TR"),
);
}
return globalKatalog.bolumOzetler;
}
export function getAllBolumSlugs(): string[] {
return [...bolumMapGetir().keys()];
}
export function getBolumBySlug(slug: string): BolumDetay | null {
const kayit = bolumMapGetir().get(slug);
if (!kayit) return null;
const placeholders = kayit.hamIsimler.map(() => "?").join(",");
const programlar = (
getDb()
.prepare(
`SELECT ${SELECT_COLS} FROM programs
WHERE isim IN (${placeholders})
ORDER BY (${EFEKTIF_SIRA} IS NULL), ${EFEKTIF_SIRA} ASC`,
)
.all(...kayit.hamIsimler) as ProgramHamSatiri[]
).map(programSatiri);
// onlisans SELECT_COLS içinde yok; tur üzerinden ayır (TYT = önlisans)
const lisansProgramlari = programlar.filter((p) => p.tur !== "TYT");
const onlisansProgramlari = programlar.filter((p) => p.tur === "TYT");
const iller = new Set<string>();
let devlet = 0;
let vakif = 0;
let bilinmeyenTur = 0;
let minSira: number | null = null;
let maxSira: number | null = null;
let minPuan: number | null = null;
let maxPuan: number | null = null;
for (const p of programlar) {
if (p.il) iller.add(p.il);
if (p.unitur === "DEVLET") devlet += 1;
else if (p.unitur) vakif += 1;
else bilinmeyenTur += 1;
const efektif = p.efektifSira;
if (efektif != null) {
if (minSira == null || efektif < minSira) minSira = efektif;
if (maxSira == null || efektif > maxSira) maxSira = efektif;
}
if (p.puanSon != null) {
if (minPuan == null || p.puanSon < minPuan) minPuan = p.puanSon;
if (maxPuan == null || p.puanSon > maxPuan) maxPuan = p.puanSon;
}
}
const ozet = getAllBolumler().find((b) => b.slug === slug)!;
return {
...ozet,
lisansProgramlari,
onlisansProgramlari,
stats: {
toplam: programlar.length,
devlet,
vakif,
bilinmeyenTur,
minSira,
maxSira,
minPuan,
maxPuan,
ilSayisi: iller.size,
},
};
}
/** Aynı kategorideki diğer bölümler, program sayısına göre (kendisi hariç). */
export function ilgiliBolumler(slug: string, limit = 8): BolumOzet[] {
const hepsi = getAllBolumler();
const kendi = hepsi.find((b) => b.slug === slug);
if (!kendi) return [];
const kendiKategoriler = new Set<string>(kendi.kategoriler);
return hepsi
.filter(
(b) =>
b.slug !== slug &&
(kendiKategoriler.size === 0 ||
b.kategoriler.some((k) => kendiKategoriler.has(k))),
)
.sort((a, b) => b.programSayisi - a.programSayisi)
.slice(0, limit);
}
// ---------- Üniversite tarafı ----------
export type UniOzet = {
slug: string;
/** tr-TR başlık düzeninde görünen ad (İstanbul Teknik Üniversitesi) */
ad: string;
il: string | null;
unitur: string | null;
programSayisi: number;
fakulteSayisi: number;
};
export type UniDetay = UniOzet & {
/**
* URAP Türkiye genel sıralaması; listede olmayan üniversitede null.
* Gösterildiği her yüzeyde URAP_KAYNAK_URL'ye aktif atıf zorunludur.
*/
urap: UrapSira | null;
/** YÖK İstatistik örgün öğrenci / öğretim üyesi sayısı; KKTC ve yurt dışında null. */
istatistik: UniIstatistik | null;
fakulteler: { fakulte: string; programlar: Program[] }[];
stats: {
minSira: number | null;
toplamKontenjan: number;
devletMi: boolean;
};
};
type UniKayit = { slug: string; ad: string; hamAdlar: string[] };
function uniMapGetir(): Map<string, UniKayit> {
if (!globalKatalog.uniMap) {
const rows = getDb()
.prepare("SELECT DISTINCT universite FROM programs")
.all() as { universite: string }[];
const map = new Map<string, UniKayit>();
for (const { universite } of rows) {
const normal = uniAdiNormalize(universite);
if (!normal) continue;
const slug = turkishSlugify(normal);
if (!slug) continue;
const kayit = map.get(slug);
if (kayit) kayit.hamAdlar.push(universite);
else
map.set(slug, {
slug,
ad: trBaslikDuzeni(normal),
hamAdlar: [universite],
});
}
if (map.size === 0) {
throw new Error("katalog: programs tablosunda üniversite yok");
}
globalKatalog.uniMap = map;
}
return globalKatalog.uniMap;
}
export function getAllUniversiteler(): UniOzet[] {
if (!globalKatalog.uniOzetler) {
const map = uniMapGetir();
const rows = getDb()
.prepare(
`SELECT universite, MAX(unitur) AS unitur, COUNT(*) AS adet
FROM programs GROUP BY universite`,
)
.all() as {
universite: string;
unitur: string | null;
adet: number;
}[];
// il: MAX(il) alfabetik en büyüğü seçip yanıltıyordu (ör. Ankara Sosyal
// Bilimler'in 12 KKTC satırı 47 Ankara satırını eziyordu); üniversitenin
// en çok programının bulunduğu il esas alınır.
const ilRows = getDb()
.prepare(
`SELECT universite, il, COUNT(*) AS adet FROM programs
WHERE il IS NOT NULL GROUP BY universite, il`,
)
.all() as { universite: string; il: string; adet: number }[];
// Fakülte sayısı: aynı üniversitenin iki yazımı ("X ÜNİV." / "X ÜNİV. (İL)")
// birleştiği için GROUP BY universite üzerinden saymak çift sayardı;
// (slug, fakulte) çifti üzerinden kesin sayılır.
const fakulteRows = getDb()
.prepare(
"SELECT DISTINCT universite, fakulte FROM programs WHERE fakulte IS NOT NULL",
)
.all() as { universite: string; fakulte: string }[];
const bySlug = new Map<string, UniOzet>();
const slugByHam = new Map<string, string>();
for (const kayit of map.values()) {
for (const ham of kayit.hamAdlar) slugByHam.set(ham, kayit.slug);
bySlug.set(kayit.slug, {
slug: kayit.slug,
ad: kayit.ad,
il: null,
unitur: null,
programSayisi: 0,
fakulteSayisi: 0,
});
}
for (const row of rows) {
const slug = slugByHam.get(row.universite);
if (!slug) continue;
const u = bySlug.get(slug)!;
u.programSayisi += row.adet;
if (!u.unitur && row.unitur) u.unitur = row.unitur;
}
const ilAdetleri = new Map<string, Map<string, number>>();
for (const row of ilRows) {
const slug = slugByHam.get(row.universite);
if (!slug) continue;
let sayac = ilAdetleri.get(slug);
if (!sayac) {
sayac = new Map();
ilAdetleri.set(slug, sayac);
}
sayac.set(row.il, (sayac.get(row.il) ?? 0) + row.adet);
}
globalKatalog.uniIlAdetleri = ilAdetleri;
for (const [slug, sayac] of ilAdetleri) {
let enIyi: string | null = null;
let enCok = 0;
for (const [il, adet] of sayac) {
if (adet > enCok) {
enIyi = il;
enCok = adet;
}
}
bySlug.get(slug)!.il = enIyi;
}
const fakulteSetleri = new Map<string, Set<string>>();
for (const row of fakulteRows) {
const slug = slugByHam.get(row.universite);
if (!slug) continue;
let set = fakulteSetleri.get(slug);
if (!set) {
set = new Set();
fakulteSetleri.set(slug, set);
}
set.add(row.fakulte);
}
for (const [slug, set] of fakulteSetleri) {
bySlug.get(slug)!.fakulteSayisi = set.size;
}
globalKatalog.uniOzetler = [...bySlug.values()].sort((a, b) =>
a.ad.localeCompare(b.ad, "tr-TR"),
);
}
return globalKatalog.uniOzetler;
}
export function getAllUniversiteSlugs(): string[] {
return [...uniMapGetir().keys()];
}
// ---------- İl (şehir) tarafı: /universiteler/[il] ----------
export type IlOzet = {
/** turkishSlugify(il): "istanbul", "saraybosna-bosna-hersek" */
slug: string;
/** Görünen ad: İstanbul, Bakü-Azerbaycan */
ad: string;
/** DB biçiminde ham il ("İSTANBUL"); harita anahtarı */
il: string;
/** Bu ilde programı olan üniversiteler (ad sırasıyla) */
uniler: UniOzet[];
programSayisi: number;
};
/**
* "BAKÜ-AZERBAYCAN" → "Bakü-Azerbaycan": trBaslikDuzeni yalnız boşlukta
* böler; yurt dışı şehirlerin tireli ülke eki de baş harfli yazılır.
*/
function ilGorunenAd(il: string): string {
return trBaslikDuzeni(il).replace(/-(\p{L})/gu, (_, harf: string) =>
`-${harf.toLocaleUpperCase("tr-TR")}`,
);
}
/**
* Üniversiteleri iline göre kümeler; sıralama üniversite sayısına göre
* azalan, eşitlikte ad. Bir üniversite **programı olan her ilde** sayılır
* (ODTÜ hem Ankara'da hem KKTC'de; öğrenci "KKTC'de okuyabileceğim
* üniversiteler" diye bakar) — böylece DB'deki her il değerinin sayfası
* vardır ve program satırlarındaki her şehir adı güvenle link olur
* (`ilSayfaHref`). `programSayisi` o ildeki program sayısıdır.
*/
export function getAllIller(): IlOzet[] {
if (!globalKatalog.iller) {
// Dev'de globalThis önbelleği HMR'ı aşar: uniOzetler eski modül koduyla
// kurulduysa il sayacı yoktur; özetler yeniden kurulur.
if (!globalKatalog.uniIlAdetleri) globalKatalog.uniOzetler = undefined;
const uniler = getAllUniversiteler();
const ilAdetleri = globalKatalog.uniIlAdetleri!;
const bySlug = new Map<string, IlOzet>();
for (const u of uniler) {
const sayac = ilAdetleri.get(u.slug);
if (!sayac) continue;
for (const [ham, adet] of sayac) {
const il = ham.trim();
if (!il) continue;
const slug = turkishSlugify(il);
if (!slug) continue;
let kayit = bySlug.get(slug);
if (!kayit) {
kayit = {
slug,
ad: ilGorunenAd(il),
il,
uniler: [],
programSayisi: 0,
};
bySlug.set(slug, kayit);
}
kayit.uniler.push(u);
kayit.programSayisi += adet;
}
}
globalKatalog.ilMap = bySlug;
globalKatalog.iller = [...bySlug.values()].sort(
(a, b) =>
b.uniler.length - a.uniler.length || a.ad.localeCompare(b.ad, "tr-TR"),
);
}
return globalKatalog.iller;
}
export function getAllIlSlugs(): string[] {
return getAllIller().map((il) => il.slug);
}
export function getIlBySlug(slug: string): IlOzet | null {
getAllIller();
return globalKatalog.ilMap!.get(slug) ?? null;
}
export function getUniversiteBySlug(slug: string): UniDetay | null {
const kayit = uniMapGetir().get(slug);
if (!kayit) return null;
const placeholders = kayit.hamAdlar.map(() => "?").join(",");
const programlar = (
getDb()
.prepare(
`SELECT ${SELECT_COLS} FROM programs
WHERE universite IN (${placeholders})
ORDER BY fakulte, (${EFEKTIF_SIRA} IS NULL), ${EFEKTIF_SIRA} ASC`,
)
.all(...kayit.hamAdlar) as ProgramHamSatiri[]
).map(programSatiri);
const gruplar = new Map<string, Program[]>();
let minSira: number | null = null;
let toplamKontenjan = 0;
for (const p of programlar) {
const anahtar = p.fakulte ?? "Diğer";
const liste = gruplar.get(anahtar);
if (liste) liste.push(p);
else gruplar.set(anahtar, [p]);
const efektif = p.efektifSira;
if (efektif != null && (minSira == null || efektif < minSira)) {
minSira = efektif;
}
toplamKontenjan += p.kontenjanSon ?? 0;
}
const ozet = getAllUniversiteler().find((u) => u.slug === slug)!;
return {
...ozet,
urap: urapSirasiBul(slug),
istatistik: uniIstatistikBul(slug),
fakulteler: [...gruplar.entries()]
.map(([fakulte, programlar]) => ({ fakulte, programlar }))
.sort((a, b) => a.fakulte.localeCompare(b.fakulte, "tr-TR")),
stats: {
minSira,
toplamKontenjan,
devletMi: ozet.unitur === "DEVLET",
},
};
}
// ---------- Yardımcılar ----------
/** Ham program isminden /bolum sayfası slug'ı (tablolarda çapraz link için). */
export function bolumSlugFromIsim(isim: string): string | null {
const slug = turkishSlugify(bolumBazAdi(isim));
return bolumMapGetir().has(slug) ? slug : null;
}
/** Ham üniversite adından /universite sayfası slug'ı. */
export function uniSlugFromAd(universite: string): string | null {
const slug = turkishSlugify(uniAdiNormalize(universite));
return uniMapGetir().has(slug) ? slug : null;
}