import path from "node:path"; import Database from "better-sqlite3"; import { KATEGORILER, kategoriBul } from "./kategoriler"; export type Program = { id: string; isim: string; universite: string; unitur: string | null; il: string | null; fakulte: string | null; tur: string; sure: number | null; sira2025: number | null; sira2024: number | null; sira2023: number | null; sira2022: number | null; sira2021: number | null; puan2025: number | null; kontenjan2025: number | null; yerlesen2025: number | null; }; export const PUAN_TURLERI = { say: "SAYISAL", ea: "EŞİT AĞIRLIK", soz: "SÖZEL", dil: "DİL", tyt: "TYT", } as const; export type PuanTuruKey = keyof typeof PUAN_TURLERI; export type DilimKey = "hayal" | "dengeli" | "garanti"; export type RankResults = { hayal: Program[]; dengeli: Program[]; garanti: Program[]; /** Dilim başına pencereye düşen TOPLAM program sayısı (limit'ten bağımsız) */ toplam: Record; }; // Dev'de hot-reload başına yeni bağlantı açılmasın diye global cache const globalForDb = globalThis as unknown as { yokatlasDb?: Database.Database }; export function getDb(): Database.Database { if (!globalForDb.yokatlasDb) { globalForDb.yokatlasDb = new Database( path.join(process.cwd(), "data", "yokatlas.db"), { readonly: true, fileMustExist: true } ); } return globalForDb.yokatlasDb; } export const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure, sira2025, sira2024, sira2023, sira2022, sira2021, puan2025, kontenjan2025, yerlesen2025`; // 2025 sıralaması yoksa (az yerleşen/yeni program) 2024'e düşer export const EFEKTIF_SIRA = "COALESCE(sira2025, sira2024)"; /** * Kullanıcının başarı sıralamasına göre programları üç dilime ayırır. * Sıralamada küçük sayı daha iyidir; program taban sıralaması kullanıcının * sıralamasından küçükse geçen yıl oraya yerleşmek daha iyi dereceyle mümkündü * demektir (hayal), büyükse daha güvenli demektir (garanti). */ export type UniturGrubu = "devlet" | "vakif"; // Vakıf sayılan unitur değerleri (bkz. DISTINCT unitur: VAKIF, VAKIF MYO, // YURTDISI VAKIF). Devlet dışı her şey "vakıf" grubuna girer. function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } { if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] }; if (grup === "vakif") return { sql: "AND unitur IS NOT NULL AND unitur <> ?", args: ["DEVLET"] }; return { sql: "", args: [] }; } /** * Dilim sınırları. Pencereler çarpan bazlı DEĞİL, açık uçlu: hayal adayın * üstündeki her şeyi, garanti 1.4× eşiğinin altındaki her şeyi kapsar. * "Yakından uzağa" sıralama + limit/offset sayesinde uçtaki gerçekçi olmayan * programlar ancak sayfalandıkça görünür; eski ×0.5/×3 pencereleri küçük * sıralamalarda (ör. 87) hiç sonuç bırakmıyordu. */ export function dilimAraligi( sira: number, dilim: DilimKey ): { alt: number; ust: number | null } { const dengeliUst = Math.round(sira * 1.4); if (dilim === "hayal") return { alt: 1, ust: sira - 1 }; if (dilim === "dengeli") return { alt: sira, ust: dengeliUst }; return { alt: dengeliUst + 1, ust: null }; } /** * Tek dilimi "yakından uzağa" sıralı ve sayfalı getirir: hayal adayın * sıralamasından yukarı (taban DESC), dengeli/garanti aşağı (taban ASC). * toplam, limit'ten bağımsız olarak dilimdeki tüm eşleşme sayısıdır. */ export function dilimAra( sira: number, turKey: PuanTuruKey, dilim: DilimKey, opts: { il?: string; iller?: string[]; uniturGrubu?: UniturGrubu; limit?: number; offset?: number; } = {} ): { programlar: Program[]; toplam: number } { const db = getDb(); const tur = PUAN_TURLERI[turKey]; const onlisans = turKey === "tyt" ? 1 : 0; const limit = opts.limit ?? 12; const offset = opts.offset ?? 0; // il (tekil, geriye uyumlu) veya iller (çoklu) — ikisi de verilirse birleşir const illerListe = [ ...(opts.il ? [opts.il] : []), ...(opts.iller ?? []), ].filter((v, i, a) => v && a.indexOf(v) === i); const ilFiltre = illerListe.length > 0 ? `AND il IN (${illerListe.map(() => "?").join(",")})` : ""; const unitur = uniturFiltreSql(opts.uniturGrubu); const { alt, ust } = dilimAraligi(sira, dilim); if (ust != null && ust < alt) return { programlar: [], toplam: 0 }; const siraKosulu = ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`; const siraArgs = ust != null ? [alt, ust] : [alt]; const args = [tur, onlisans, ...illerListe, ...unitur.args, ...siraArgs]; const base = `FROM programs WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql} AND ${EFEKTIF_SIRA} IS NOT NULL AND ${siraKosulu}`; // hayal: sınıra en yakın (en büyük taban) önce; diğerleri en yakından uzağa const yon = dilim === "hayal" ? "DESC" : "ASC"; const programlar = db .prepare( `SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ${yon} LIMIT ? OFFSET ?` ) .all(...args, limit, offset) as Program[]; const toplam = ( db.prepare(`SELECT COUNT(*) AS adet ${base}`).get(...args) as { adet: number; } ).adet; return { programlar, toplam }; } export function searchByRank( sira: number, turKey: PuanTuruKey, opts: { il?: string; iller?: string[]; uniturGrubu?: UniturGrubu; limitPerBucket?: number; } = {} ): RankResults { const ortak = { il: opts.il, iller: opts.iller, uniturGrubu: opts.uniturGrubu, limit: opts.limitPerBucket ?? 12, }; const hayal = dilimAra(sira, turKey, "hayal", ortak); const dengeli = dilimAra(sira, turKey, "dengeli", ortak); const garanti = dilimAra(sira, turKey, "garanti", ortak); return { hayal: hayal.programlar, dengeli: dengeli.programlar, garanti: garanti.programlar, toplam: { hayal: hayal.toplam, dengeli: dengeli.toplam, garanti: garanti.toplam, }, }; } export type SihirbazFacetleri = { kategoriler: { ad: string; adet: number }[]; iller: { il: string; adet: number }[]; uniturler: { grup: UniturGrubu; adet: number }[]; /** Penceredeki (filtre uygulanmamış) toplam program sayısı */ toplam: number; }; // Sihirbaz bubble'ları için erişilebilir program penceresi: hayal için // gerçekçi alt sınırdan (sira*0.5) itibaren AÇIK uçlu — garanti tarafı artık // sınırsız olduğundan üst kesim yok. Böylece 500B sıradaki aday Tıp bubble'ı // görmez; 5B sıradaki görür. // // Kademeli (cascade) filtre: adımlar birbirini süzer. Kategoriler yalnızca // pencereden; iller seçili kategorilerden; üniversite tipi kategori+il'den // hesaplanır. Böylece "Mühendislik + İzmir" seçen aday 3. adımda yalnızca // İzmir'deki mühendislik programlarının devlet/vakıf dağılımını görür. export function rankWindowFacets( sira: number, turKey: PuanTuruKey, secim: { kategoriler?: string[]; iller?: string[] } = {} ): SihirbazFacetleri { const db = getDb(); const tur = PUAN_TURLERI[turKey]; const onlisans = turKey === "tyt" ? 1 : 0; const altSinir = Math.round(sira * 0.5); const satirlar = db .prepare( `SELECT isim, il, unitur FROM programs WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL AND ${EFEKTIF_SIRA} >= ?` ) .all(tur, onlisans, altSinir) as { isim: string; il: string | null; unitur: string | null; }[]; // isim → kategori eşlemesi pahalı (12 regex); tekrar eden isimler için önbellek const kategoriCache = new Map(); const kategorileriGetir = (isim: string): string[] => { let k = kategoriCache.get(isim); if (!k) { k = kategoriBul(isim); kategoriCache.set(isim, k); } return k; }; const seciliKategoriler = secim.kategoriler ?? []; const kategoriUyar = (isim: string) => seciliKategoriler.length === 0 || kategorileriGetir(isim).some((k) => seciliKategoriler.includes(k)); const seciliIller = new Set(secim.iller ?? []); const ilUyar = (il: string | null) => seciliIller.size === 0 || (il != null && seciliIller.has(il)); const kategoriSay = new Map(); const ilSay = new Map(); let devlet = 0; let vakif = 0; for (const { isim, il, unitur } of satirlar) { // 1. adım: kategoriler yalnızca pencereye bağlı (filtre uygulanmaz) for (const k of kategorileriGetir(isim)) { kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + 1); } if (!kategoriUyar(isim)) continue; // 2. adım: iller seçili kategorilerle süzülür if (il) ilSay.set(il, (ilSay.get(il) ?? 0) + 1); if (!ilUyar(il)) continue; // 3. adım: üniversite tipi kategori + il ile süzülür if (unitur === "DEVLET") devlet += 1; else if (unitur) vakif += 1; } const kategoriler = KATEGORILER.map((ad) => ({ ad, adet: kategoriSay.get(ad) ?? 0, })) .filter((k) => k.adet >= 3) // gürültüyü ele .sort((a, b) => b.adet - a.adet); const iller = [...ilSay.entries()] .map(([il, adet]) => ({ il, adet })) .sort((a, b) => b.adet - a.adet) .slice(0, 15); const uniturler: { grup: UniturGrubu; adet: number }[] = []; if (devlet > 0) uniturler.push({ grup: "devlet", adet: devlet }); if (vakif > 0) uniturler.push({ grup: "vakif", adet: vakif }); return { kategoriler, iller, uniturler, toplam: satirlar.length }; }