// server-only guard'ı bilinçli yok: scripts/tadimlik-uret.ts bu modülü düz // node (tsx) altında import ediyor; server-only paketi orada fırlar. import path from "node:path"; import Database from "better-sqlite3"; import { KATEGORILER, kategoriBul } from "./kategoriler"; import { EFEKTIF_SIRA_SQL, ILK_YIL, SON_YIL, YILLAR } from "./veri-yillari"; import { turkishSlugify } from "./slug"; import { varyantEtiketi } from "./program-varyant"; import { PUAN_TURLERI, type ArsivProgram, type DilimKey, type KontenjanDilimi, type KontenjanTurleri, type KontenjanTuruAnahtari, type Program, type ProgramGecmisSatiri, type ProgramNetSatiri, type PuanTuruKey, type RankResults, type SihirbazFacetleri, type UniturGrubu, } from "@/types/yokatlas"; // 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; } // Yıl ekli kolonlar yalnız burada, `YILLAR`'dan üretilerek adlandırılır; DB // dışına yılsız `Program` çıkar. SELECT_COLS ile okunan her satır // `programSatiri` eşleyicisinden geçmelidir (ham satır `Program` değildir). // Burs/dil/öğretim türü `program_ozellik`'ten ilişkili alt sorguyla gelir // (id PRIMARY KEY). Bütün SELECT_COLS kullanımları takma adsız `FROM programs` // olduğu için `programs.id` çözümlenir; `programs_arsiv` (ARSIV_SELECT) bu // kolonları almaz (arşivde özellik yok). Tablo yokluğu koruması gerekmez: // data/yokatlas.db depoda ve Docker seed'inde tabloyla gelir. export const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure, ${YILLAR.map((yil) => `sira${yil} AS s${yil}`).join(", ")}, puan${SON_YIL} AS puanSon, kontenjan${SON_YIL} AS kontenjanSon, yerlesen${SON_YIL} AS yerlesenSon, (SELECT burs_orani FROM program_ozellik o WHERE o.id = programs.id) AS bursOrani, (SELECT ogrenim_dili FROM program_ozellik o WHERE o.id = programs.id) AS ogrenimDili, (SELECT ogrenim_turu FROM program_ozellik o WHERE o.id = programs.id) AS ogrenimTuru`; // Son yılın sıralaması yoksa (az yerleşen/yeni program) bir önceki yıla düşer. // İfade indeks sözleşmesidir; tek kaynağı lib/veri-yillari.ts. export const EFEKTIF_SIRA = EFEKTIF_SIRA_SQL; /** SELECT_COLS ile okunan ham satır: sıra kolonları `s` takma adıyla, * varyant ise üç ham özellik alanı olarak gelir. */ export type ProgramHamSatiri = Omit< Program, "siraGecmisi" | "efektifSira" | "siraSon" | "varyant" > & Record<`s${number}`, number | null> & { bursOrani: string | null; ogrenimDili: string | null; ogrenimTuru: string | null; }; /** Ham DB satırını yılsız `Program`'a çevirir. */ export function programSatiri(row: ProgramHamSatiri): Program { const siraGecmisi = YILLAR.map((yil) => row[`s${yil}`] ?? null); const siraSon = siraGecmisi[siraGecmisi.length - 1] ?? null; return { id: row.id, isim: row.isim, universite: row.universite, unitur: row.unitur, il: row.il, fakulte: row.fakulte, tur: row.tur, sure: row.sure, siraGecmisi, // EFEKTIF_SIRA ile aynı kural: son yıl, yoksa bir önceki yıl efektifSira: siraSon ?? siraGecmisi[siraGecmisi.length - 2] ?? null, siraSon, puanSon: row.puanSon, kontenjanSon: row.kontenjanSon, yerlesenSon: row.yerlesenSon, varyant: varyantEtiketi(row), }; } /** * 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). */ /** * Danışman profilinden gelen program özelliği süzgeci (program_ozellik * tablosu; bkz. lib/program-ozellik.ts). Tablo yoksa süzgeç uygulanmaz. * Devlet programlarında ücret/burs NULL'dur ve her zaman geçer. */ export type OzellikSuzgeci = { dil?: "Türkçe" | "İngilizce"; yalnizOrgun?: boolean; yalnizBurslu?: boolean; ucretUst?: number; }; let ozellikTablosuVar: boolean | null = null; function ozellikTablosuVarMi(): boolean { if (ozellikTablosuVar == null) { ozellikTablosuVar = getDb() .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='program_ozellik'") .get() !== undefined; } return ozellikTablosuVar; } function ozellikFiltreSql(o?: OzellikSuzgeci): { sql: string; args: (string | number)[] } { if (!o) return { sql: "", args: [] }; const kosul: string[] = []; const args: (string | number)[] = []; if (o.dil) { kosul.push("ogrenim_dili LIKE ?"); args.push(`${o.dil}%`); } if (o.yalnizOrgun) kosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')"); if (o.yalnizBurslu) kosul.push("(burs_orani IS NULL OR burs_orani = 'Burslu')"); if (o.ucretUst) { kosul.push("(ucret IS NULL OR burs_orani = 'Burslu' OR ucret <= ?)"); args.push(o.ucretUst); } if (kosul.length === 0) return { sql: "", args: [] }; if (!ozellikTablosuVarMi()) return { sql: "", args: [] }; return { sql: `AND id IN (SELECT id FROM program_ozellik WHERE ${kosul.join(" AND ")})`, args, }; } /** * Başarı sırası barajı: `program_ozellik.min_basari_sirasi` programı tercih * edebilmek için gereken en düşük sıradır (YÖK Atlas'tan, kılavuz yılına ait; * ör. tıp 50.000). Adayın sırası bundan kötüyse o programı yazamaz. Barajı * boş olan program (barajsız ya da veride eksik) süzülmez. */ function barajFiltreSql(sira: number): { sql: string; args: number[] } { if (!ozellikTablosuVarMi()) return { sql: "", args: [] }; return { sql: "AND id NOT IN (SELECT id FROM program_ozellik WHERE min_basari_sirasi < ?)", args: [sira], }; } 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; ozellik?: OzellikSuzgeci; /** Adayın sırasıyla yazamayacağı (baraj dışı) programları çıkar */ barajDisiHaric?: boolean; 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 ozellik = ozellikFiltreSql(opts.ozellik); const baraj = opts.barajDisiHaric ? barajFiltreSql(sira) : { sql: "", args: [] }; 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, ...ozellik.args, ...baraj.args, ...siraArgs, ]; const base = `FROM programs WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql} ${ozellik.sql} ${baraj.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 satirlar = db .prepare( `SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ${yon} LIMIT ? OFFSET ?` ) .all(...args, limit, offset) as ProgramHamSatiri[]; const toplam = ( db.prepare(`SELECT COUNT(*) AS adet ${base}`).get(...args) as { adet: number; } ).adet; return { programlar: satirlar.map(programSatiri), toplam }; } export function searchByRank( sira: number, turKey: PuanTuruKey, opts: { il?: string; iller?: string[]; uniturGrubu?: UniturGrubu; ozellik?: OzellikSuzgeci; limitPerBucket?: number; } = {} ): RankResults { const ortak = { il: opts.il, iller: opts.iller, uniturGrubu: opts.uniturGrubu, ozellik: opts.ozellik, 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, }, }; } /** * Aday havuzunun ham girdisi: üç dilimin TAMAMI, yakından uzağa sıralı * (limit yok). Alan (kategori) DB'de kolon değil, JS'te isim+fakülteden * türüyor; süzgeç limitten SONRA uygulanınca sıraya en yakın satırlar her * alandan karışık geldiği için seçilen alan olduğundan az görünüyordu. Bu * yüzden havuz dilimin tamamını alır, alan/il süzgecini kendisi uygular ve * en son kırpar (bkz. lib/rapor-havuzu.ts). En büyük puan türü ~9 bin satır. * Adayın başarı sırası barajını geçemediği programlar buraya hiç girmez. */ export function havuzAdaylari( sira: number, turKey: PuanTuruKey, opts: { uniturGrubu?: UniturGrubu; ozellik?: OzellikSuzgeci } = {} ): Record { // LIMIT -1: SQLite'ta "sınırsız" const ortak = { ...opts, barajDisiHaric: true, limit: -1 }; return { hayal: dilimAra(sira, turKey, "hayal", ortak).programlar, dengeli: dilimAra(sira, turKey, "dengeli", ortak).programlar, garanti: dilimAra(sira, turKey, "garanti", ortak).programlar, }; } /** * Programa yerleşen son kişinin yıl bazlı netleri, yeniden eskiye sıralı. * Veri scripts/detay.ts ile YÖK Atlas /api/netler/search'ten doldurulur. */ export function netlerGetir(programId: string): ProgramNetSatiri[] { return getDb() .prepare( `SELECT yil, puanTuru, tabanPuan, obp, katsayi, tytTrkNet, tytSosNet, tytMatNet, tytFenNet, aytMatNet, aytFizNet, aytKimNet, aytBioNet, aytTdeNet, aytTrh1Net, aytCog1Net, aytTrh2Net, aytCog2Net, aytDinNet, aytFelNet, ydtYdilNet FROM netler WHERE program_id = ? ORDER BY yil DESC` ) .all(programId) as ProgramNetSatiri[]; } /** Güncel kılavuzdaki tek program; arşivlenmiş ya da bilinmeyen id'de null. */ export function programGetir(id: string): Program | null { const row = getDb() .prepare(`SELECT ${SELECT_COLS} FROM programs WHERE id = ?`) .get(id) as ProgramHamSatiri | undefined; return row ? programSatiri(row) : null; } // ── Yıl bazlı geçmiş, kontenjan türleri, arşiv ─────────────────────────── // Bu alanlar `Program` tipine girmez: liste satırları client'a serileşiyor, // her satırı 4×6 kolonla şişirmek istemiyoruz. Program ayrıntı çekmecesi // (/api/program-ayrinti), danışman araçları ve uyum puanı buradan okur. let programKolonlari: Set | null = null; /** `programs` tablosunun kolon adları (bir kez okunur). */ function programKolonlariGetir(): Set { if (!programKolonlari) { programKolonlari = new Set( (getDb().prepare("PRAGMA table_info(programs)").all() as { name: string }[]).map( (c) => c.name ) ); } return programKolonlari; } /** * Programın yıl bazlı geçmişi, YENİDEN ESKİYE. Veri penceresi `YILLAR`; * kolonu olmayan yıl atlanır. Bilinmeyen id'de boş dizi. */ export function programGecmisiGetir(id: string): ProgramGecmisSatiri[] { const kolonlar = programKolonlariGetir(); const yillar = YILLAR.filter((y) => kolonlar.has(`sira${y}`)); if (yillar.length === 0) return []; const secim = yillar .map((y) => `sira${y} AS s${y}, puan${y} AS p${y}, kontenjan${y} AS k${y}, yerlesen${y} AS y${y}`) .join(", "); const row = getDb() .prepare(`SELECT ${secim} FROM programs WHERE id = ?`) .get(id) as Record | undefined; if (!row) return []; return [...yillar].reverse().map((yil) => ({ yil, sira: row[`s${yil}`] ?? null, puan: row[`p${yil}`] ?? null, kontenjan: row[`k${yil}`] ?? null, yerlesen: row[`y${yil}`] ?? null, })); } /** Kontenjan türü kolonu öneki → KontenjanTurleri alanı. */ const KONTENJAN_TURU_KOLON = { gk: "genel", obk: "okulBirincisi", sgy: "sehitGaziYakini", dprm: "depremzede", y34k: "kadin34", } as const; /** KontenjanTurleri alanı → kolon öneki (SQL süzgeçleri `${onek}${kontenjanTuruYili()}`). */ export const KONTENJAN_TURU_ONEK: Record = { genel: "gk", okulBirincisi: "obk", sehitGaziYakini: "sgy", depremzede: "dprm", kadin34: "y34k", }; let kontenjanTuruYiliOnbellek: number | null | undefined; /** * Kontenjan türü kırılımının bulunduğu en yeni yıl (`gk` kolonu olan). * Kırılım bir sonraki kılavuzla açıklandığı için SON_YIL'dan bir geride * olabilir; hiç yoksa null (eski DB). */ export function kontenjanTuruYili(): number | null { if (kontenjanTuruYiliOnbellek !== undefined) return kontenjanTuruYiliOnbellek; const kolonlar = programKolonlariGetir(); let bulunan: number | null = null; for (let y = SON_YIL; y >= ILK_YIL; y--) { if (kolonlar.has(`gk${y}`) && kolonlar.has(`y34k${y}`)) { bulunan = y; break; } } kontenjanTuruYiliOnbellek = bulunan; return bulunan; } function kontenjanTurleriSatiri( yil: number, row: Record ): KontenjanTurleri { const dilim = (onek: string): KontenjanDilimi => ({ kontenjan: row[`${onek}${yil}`] ?? null, yerlesen: row[`${onek}Y${yil}`] ?? null, }); return { yil, genel: dilim("gk"), okulBirincisi: dilim("obk"), sehitGaziYakini: dilim("sgy"), depremzede: dilim("dprm"), kadin34: dilim("y34k"), }; } function kontenjanTuruSecimi(yil: number): string { return Object.keys(KONTENJAN_TURU_KOLON) .map((onek) => `${onek}${yil}, ${onek}Y${yil}`) .join(", "); } /** Tek programın kontenjan türleri; kırılım kolonu ya da program yoksa null. */ export function kontenjanTurleriGetir(id: string): KontenjanTurleri | null { const yil = kontenjanTuruYili(); if (yil == null) return null; const row = getDb() .prepare(`SELECT ${kontenjanTuruSecimi(yil)} FROM programs WHERE id = ?`) .get(id) as Record | undefined; return row ? kontenjanTurleriSatiri(yil, row) : null; } const IN_PARCA = 500; /** Çok programın kontenjan türleri (uyum puanı, arama sonuçları); id → satır. */ export function kontenjanTurleriGetirToplu( ids: readonly string[] ): Record { const sonuc: Record = {}; const yil = kontenjanTuruYili(); if (yil == null || ids.length === 0) return sonuc; const db = getDb(); for (let i = 0; i < ids.length; i += IN_PARCA) { const parca = ids.slice(i, i + IN_PARCA); const satirlar = db .prepare( `SELECT id, ${kontenjanTuruSecimi(yil)} FROM programs WHERE id IN (${parca.map(() => "?").join(",")})` ) .all(...parca) as (Record & { id: string })[]; for (const r of satirlar) sonuc[r.id] = kontenjanTurleriSatiri(yil, r); } return sonuc; } // Katlanmış (Türkçe harf duyarsız) ad eşleştirme: SQLite LIKE "isletme"yi // İşletme'yle, "bogazici"yi Boğaziçi'yle eşlemez. Kolonun ayrık değerleri // (birkaç bin) tablo başına bir kez okunur, eşleşme JS'te yapılır. Danışman // araçları (program_ara) ve arşiv araması aynı yardımcıyı kullanır. type AyrikDeger = { ham: string; katli: string }; const ayrikDegerler = new Map(); export function ayrikListe( kolon: "isim" | "universite", tablo: "programs" | "programs_arsiv" = "programs" ): AyrikDeger[] { const anahtar = `${tablo}.${kolon}`; let liste = ayrikDegerler.get(anahtar); if (!liste) { liste = (getDb().prepare(`SELECT DISTINCT ${kolon} AS v FROM ${tablo}`).all() as { v: string | null }[]) .filter((x): x is { v: string } => x.v != null) .map((x) => ({ ham: x.v, katli: turkishSlugify(x.v) })); ayrikDegerler.set(anahtar, liste); } return liste; } /** Kolonun, katlanmış hâli `katliSorgu`yu içeren ham değerleri. */ export function katliEslesenler( kolon: "isim" | "universite", katliSorgu: string, tablo: "programs" | "programs_arsiv" = "programs" ): string[] { return ayrikListe(kolon, tablo) .filter((x) => x.katli.includes(katliSorgu)) .map((x) => x.ham); } let arsivTablosuVar: boolean | null = null; function arsivTablosuVarMi(): boolean { if (arsivTablosuVar == null) { arsivTablosuVar = getDb() .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='programs_arsiv'") .get() !== undefined; } return arsivTablosuVar; } const ARSIV_SELECT = `id, isim, universite, unitur, il, fakulte, tur, sure, son_kilavuz_yili AS sonKilavuzYili, eski_kod AS eskiKod, ${YILLAR.map((y) => `sira${y} AS s${y}, puan${y} AS p${y}, kontenjan${y} AS k${y}, yerlesen${y} AS y${y}`).join(", ")}`; function arsivSatiri(row: Record): ArsivProgram { // Dolu son yerleştirme yılı: sırası ya da kontenjanı olan en yeni yıl let sonYil: number | null = null; for (const y of [...YILLAR].reverse()) { if (row[`s${y}`] != null || row[`k${y}`] != null) { sonYil = y; break; } } const deger = (onek: string) => sonYil == null ? null : ((row[`${onek}${sonYil}`] as number | null) ?? null); return { id: String(row.id), isim: String(row.isim), universite: String(row.universite), unitur: (row.unitur as string | null) ?? null, il: (row.il as string | null) ?? null, fakulte: (row.fakulte as string | null) ?? null, tur: String(row.tur), sure: (row.sure as number | null) ?? null, sonKilavuzYili: (row.sonKilavuzYili as number | null) ?? null, sonYil, sonSira: deger("s"), sonPuan: deger("p"), sonKontenjan: deger("k"), sonYerlesen: deger("y"), eskiKod: (row.eskiKod as string | null) ?? null, }; } function inKosulu(kolon: string, degerler: string[]): string { return degerler.length ? `${kolon} IN (${degerler.map(() => "?").join(",")})` : "0 = 1"; } /** * Arşivdeki (güncel kılavuzda olmayan) programları arar: bölüm adı parçası, * üniversite adı parçası (katlanmış), il (DB biçimi). En yeni son kılavuz yılı * önce. Tablo yoksa boş. */ export function arsivProgramAra( a: { arama?: string; universite?: string; il?: string; limit?: number } ): { toplam: number; programlar: ArsivProgram[] } { if (!arsivTablosuVarMi()) return { toplam: 0, programlar: [] }; const kosullar: string[] = []; const args: (string | number)[] = []; if (a.arama) { const adlar = katliEslesenler("isim", turkishSlugify(a.arama), "programs_arsiv"); kosullar.push(inKosulu("isim", adlar)); args.push(...adlar); } if (a.universite) { const adlar = katliEslesenler("universite", turkishSlugify(a.universite), "programs_arsiv"); kosullar.push(inKosulu("universite", adlar)); args.push(...adlar); } if (a.il) { kosullar.push("il = ?"); args.push(a.il); } if (kosullar.length === 0) return { toplam: 0, programlar: [] }; const where = `WHERE ${kosullar.join(" AND ")}`; const db = getDb(); const toplam = (db.prepare(`SELECT COUNT(*) AS n FROM programs_arsiv ${where}`).get(...args) as { n: number }).n; const satirlar = db .prepare( `SELECT ${ARSIV_SELECT} FROM programs_arsiv ${where} ORDER BY son_kilavuz_yili DESC, universite, isim LIMIT ?` ) .all(...args, a.limit ?? 10) as Record[]; return { toplam, programlar: satirlar.map(arsivSatiri) }; } /** Arşivdeki tek program (id `kod` ya da `kod~yıl` biçiminde olabilir). */ export function arsivProgramGetir(id: string): ArsivProgram | null { if (!arsivTablosuVarMi()) return null; const row = getDb() .prepare(`SELECT ${ARSIV_SELECT} FROM programs_arsiv WHERE id = ? OR id LIKE ? ORDER BY son_kilavuz_yili DESC LIMIT 1`) .get(id, `${id}~%`) as Record | undefined; return row ? arsivSatiri(row) : null; } /** * Kayıtlı manuel liste satırlarının güncel efektif sırası (son yıl, yoksa bir * önceki). Kayıtlar efektif sırayı ekleme anının snapshot'ı olarak taşır; yeni * yerleştirme yılı yüklenince bununla tazelenir. Güncel kılavuzda olmayan * (arşivlenmiş) id'ler sonuçta yer almaz — çağıran taraf eski değeri korur. */ export function efektifSiralarGetir( ids: readonly string[] ): Record { if (ids.length === 0) return {}; const satirlar = getDb() .prepare( `SELECT id, ${EFEKTIF_SIRA} AS efektif FROM programs WHERE id IN (${ids.map(() => "?").join(",")})` ) .all(...ids) as { id: string; efektif: number | null }[]; const sonuc: Record = {}; for (const s of satirlar) sonuc[s.id] = s.efektif; return sonuc; } // 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, fakulte, il, unitur FROM programs WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL AND ${EFEKTIF_SIRA} >= ?` ) .all(tur, onlisans, altSinir) as { isim: string; fakulte: string | null; il: string | null; unitur: string | null; }[]; // isim+fakülte → kategori eşlemesi pahalı (regexler); tekrar edenler için önbellek const kategoriCache = new Map(); const kategorileriGetir = ( isim: string, fakulte: string | null ): string[] => { const anahtar = `${isim}|${fakulte ?? ""}`; let k = kategoriCache.get(anahtar); if (!k) { k = kategoriBul(isim, fakulte); kategoriCache.set(anahtar, k); } return k; }; const seciliKategoriler = secim.kategoriler ?? []; const kategoriUyar = (isim: string, fakulte: string | null) => seciliKategoriler.length === 0 || kategorileriGetir(isim, fakulte).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, fakulte, il, unitur } of satirlar) { // 1. adım: kategoriler yalnızca pencereye bağlı (filtre uygulanmaz) for (const k of kategorileriGetir(isim, fakulte)) { kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + 1); } if (!kategoriUyar(isim, fakulte)) 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); // Penceredeki programı olan TÜM iller döner (81 il + KKTC/yurtdışı); // sıralama/gruplama UI'da yapılır. Kırpma yok: kullanıcı her ili görebilmeli. const iller = [...ilSay.entries()] .map(([il, adet]) => ({ il, adet })) .sort((a, b) => b.adet - a.adet); 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 }; }