diff --git a/package-lock.json b/package-lock.json index 6fd06e4..cc6ec59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "ddd", + "name": "tercihai", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ddd", + "name": "tercihai", "version": "0.1.0", "dependencies": { "better-sqlite3": "^12.11.1", @@ -19,6 +19,7 @@ "react-dom": "19.2.4", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", + "turkey-map-react": "^2.0.5", "tw-animate-css": "^1.4.0" }, "devDependencies": { @@ -11877,6 +11878,16 @@ "node": "*" } }, + "node_modules/turkey-map-react": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/turkey-map-react/-/turkey-map-react-2.0.5.tgz", + "integrity": "sha512-0EwmyQcbVXrSThj88vEUPcVUKV9FItrOrWPbATCRLQb/0JRUUnSEziUv32JrPSQV2rhHIgO/Hxb7s5mV0og5fQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", diff --git a/package.json b/package.json index 20a15a7..527c1b6 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "ddd", + "name": "tercihai", "version": "0.1.0", "private": true, "scripts": { @@ -22,6 +22,7 @@ "react-dom": "19.2.4", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", + "turkey-map-react": "^2.0.5", "tw-animate-css": "^1.4.0" }, "devDependencies": { diff --git a/src/app/sonuc/page.tsx b/src/app/sonuc/page.tsx index 3003cad..d54fd2c 100644 --- a/src/app/sonuc/page.tsx +++ b/src/app/sonuc/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import type { Metadata } from "next"; import { ArrowLeft, + MapPin, Minus, Rocket, Scale, @@ -9,6 +10,7 @@ import { Sparkles, TrendingDown, TrendingUp, + X, } from "lucide-react"; import { PUAN_TURLERI, @@ -16,6 +18,7 @@ import { type Program, type PuanTuruKey, } from "@/lib/db"; +import { BolgeHaritasi, type IlOzeti } from "@/components/bolge-haritasi"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -135,7 +138,7 @@ function ProgramTable({ programs }: { programs: Program[] }) { export default async function SonucPage({ searchParams, }: { - searchParams: Promise<{ sira?: string; tur?: string }>; + searchParams: Promise<{ sira?: string; tur?: string; il?: string }>; }) { const params = await searchParams; const sira = Number.parseInt(params.sira ?? "", 10); @@ -143,6 +146,7 @@ export default async function SonucPage({ params.tur && params.tur in PUAN_TURLERI ? (params.tur as PuanTuruKey) : "say"; + const seciliIl = params.il?.trim() || null; if (!Number.isFinite(sira) || sira < 1 || sira > 4_000_000) { return ( @@ -163,7 +167,26 @@ export default async function SonucPage({ ); } - const results = searchByRank(sira, turKey); + const results = searchByRank(sira, turKey, { + il: seciliIl ?? undefined, + }); + // Harita her zaman filtresiz dağılımı gösterir ki il değiştirilebilsin + const haritaSonuclari = seciliIl ? searchByRank(sira, turKey) : results; + + // Harita için il bazında özet (81 il haritada; KKTC/yurtdışı ayrıca listelenir) + const iller: Record = {}; + const dilimler = [ + ["hayal", haritaSonuclari.hayal], + ["dengeli", haritaSonuclari.dengeli], + ["garanti", haritaSonuclari.garanti], + ] as const; + for (const [dilim, programs] of dilimler) { + for (const p of programs) { + if (!p.il) continue; + iller[p.il] ??= { hayal: 0, dengeli: 0, garanti: 0 }; + iller[p.il][dilim]++; + } + } const sections = [ { @@ -238,13 +261,58 @@ export default async function SonucPage({ variant={key === turKey ? "default" : "outline"} className="cursor-pointer" > - + {TUR_LABELS[key]} ))} + {seciliIl ? ( +
+ +

+ Yalnızca {seciliIl}{" "} + programları gösteriliyor +

+ +
+ ) : null} + + {/* Bölge haritası */} +
+

+ Nerede okuyacaksın? +

+

+ Aşağıdaki önerilerin illere dağılımı. Koyu renk, o ilde daha fazla + öneri olduğunu gösterir. Bir ile tıklayarak listeyi o ile + daraltabilir, tekrar tıklayarak filtreyi kaldırabilirsin. +

+
+ +
+
+
{sections.map((s) => (
diff --git a/src/components/bolge-haritasi.tsx b/src/components/bolge-haritasi.tsx new file mode 100644 index 0000000..49ce991 --- /dev/null +++ b/src/components/bolge-haritasi.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { cities } from "turkey-map-react/lib/data"; + +export type IlOzeti = { + hayal: number; + dengeli: number; + garanti: number; +}; + +type Props = { + /** DB'deki il adıyla (büyük harf Türkçe) program sayıları */ + iller: Record; + sira: number; + turKey: string; + seciliIl: string | null; +}; + +// Harita adını DB formatına çevirir: "Hakkâri" → "HAKKARİ" +function normalizeIl(name: string): string { + return name + .toLocaleUpperCase("tr-TR") + .replace(/Â/g, "A") + .replace(/Î/g, "İ") + .replace(/Û/g, "U"); +} + +function toplam(o: IlOzeti): number { + return o.hayal + o.dengeli + o.garanti; +} + +type Secim = { + name: string; + ozet: IlOzeti; + x: number; + y: number; +}; + +export function BolgeHaritasi({ iller, sira, turKey, seciliIl }: Props) { + const [secim, setSecim] = useState(null); + const kapsayici = useRef(null); + const router = useRouter(); + + function filtrele(ilKey: string) { + const url = + ilKey === seciliIl + ? `/sonuc?sira=${sira}&tur=${turKey}` + : `/sonuc?sira=${sira}&tur=${turKey}&il=${encodeURIComponent(ilKey)}`; + router.push(url, { scroll: false }); + } + + const maxToplam = Math.max(1, ...Object.values(iller).map((o) => toplam(o))); + + // Haritada karşılığı olmayan yerler (KKTC, yurtdışı kampüsler) + const haritaIlleri = new Set(cities.map((c) => normalizeIl(c.name))); + const haritaDisi = Object.entries(iller).filter( + ([il]) => !haritaIlleri.has(il) + ); + + function sec(el: SVGPathElement, name: string, ozet: IlOzeti) { + const kutu = kapsayici.current?.getBoundingClientRect(); + const r = el.getBoundingClientRect(); + if (!kutu) return; + setSecim({ + name, + ozet, + x: r.x - kutu.x + r.width / 2, + y: r.y - kutu.y, + }); + } + + return ( +
+ { + if ((e.target as Element).tagName !== "path") setSecim(null); + }} + > + {/* Seçili il en son çizilir ki turuncu konturu komşu iller örtmesin */} + {[...cities] + .sort((a, b) => + (normalizeIl(a.name) === seciliIl ? 1 : 0) - + (normalizeIl(b.name) === seciliIl ? 1 : 0) + ) + .map((city) => { + const key = normalizeIl(city.name); + const ozet = iller[key]; + const yogunluk = ozet ? toplam(ozet) / maxToplam : 0; + const secili = key === seciliIl; + return ( + filtrele(key) : () => setSecim(null) + } + onMouseEnter={ + ozet + ? (e) => sec(e.currentTarget, city.name, ozet) + : undefined + } + onMouseLeave={ozet ? () => setSecim(null) : undefined} + > + + {ozet + ? `${city.name}: ${toplam(ozet)} program — filtrelemek için tıkla` + : city.name} + + + ); + })} + + + {secim ? ( +
+

{secim.name}

+

+ {toplam(secim.ozet)} program + + {secim.ozet.hayal} hayal · {secim.ozet.dengeli} dengeli ·{" "} + {secim.ozet.garanti} garanti + + + {normalizeIl(secim.name) === seciliIl + ? "Filtreyi kaldırmak için tıkla" + : "Filtrelemek için tıkla"} + +

+
+ ) : null} + +
+ az + + çok program +
+ + {haritaDisi.length > 0 ? ( +

+ Harita dışı:{" "} + {haritaDisi + .map( + ([il, ozet]) => `${il.toLocaleLowerCase("tr-TR")} (${toplam(ozet)})` + ) + .join(", ")} +

+ ) : null} +
+ ); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index e6c1ed2..9b69520 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -65,14 +65,17 @@ const EFEKTIF_SIRA = "COALESCE(sira2025, sira2024)"; export function searchByRank( sira: number, turKey: PuanTuruKey, - limitPerBucket = 12 + opts: { il?: string; limitPerBucket?: number } = {} ): RankResults { const db = getDb(); const tur = PUAN_TURLERI[turKey]; const onlisans = turKey === "tyt" ? 1 : 0; + const limitPerBucket = opts.limitPerBucket ?? 12; + const ilFiltre = opts.il ? "AND il = ?" : ""; + const ilArgs = opts.il ? [opts.il] : []; const base = `FROM programs - WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL + WHERE tur = ? AND onlisans = ? ${ilFiltre} AND ${EFEKTIF_SIRA} IS NOT NULL AND ${EFEKTIF_SIRA} BETWEEN ? AND ?`; // hayal: kullanıcıdan daha iyi taban sıralaması (sınıra en yakın olanlar önce) @@ -80,12 +83,26 @@ export function searchByRank( .prepare( `SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} DESC LIMIT ?` ) - .all(tur, onlisans, Math.round(sira * 0.5), sira - 1, limitPerBucket); + .all( + tur, + onlisans, + ...ilArgs, + Math.round(sira * 0.5), + sira - 1, + limitPerBucket + ); // dengeli: tabanı kullanıcının sıralaması civarında const dengeli = db .prepare(`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ASC LIMIT ?`) - .all(tur, onlisans, sira, Math.round(sira * 1.4), limitPerBucket); + .all( + tur, + onlisans, + ...ilArgs, + sira, + Math.round(sira * 1.4), + limitPerBucket + ); // garanti: tabanı belirgin şekilde altında const garanti = db @@ -93,6 +110,7 @@ export function searchByRank( .all( tur, onlisans, + ...ilArgs, Math.round(sira * 1.4) + 1, Math.round(sira * 3), limitPerBucket