Add il bazlı filtreleme ve Türkiye harita bileşeni

Sonuç sayfasına bölge haritası ile il seçimi eklendi; searchByRank
artık il filtresini destekliyor. Paket adı tercihai olarak güncellendi.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bilalgursen
2026-07-21 12:05:29 +03:00
parent 87f265ec15
commit 871012e87d
5 changed files with 290 additions and 10 deletions

15
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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<string, IlOzeti> = {};
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"
>
<Link href={`/sonuc?sira=${sira}&tur=${key}`}>
<Link
href={`/sonuc?sira=${sira}&tur=${key}${
seciliIl ? `&il=${encodeURIComponent(seciliIl)}` : ""
}`}
>
{TUR_LABELS[key]}
</Link>
</Button>
))}
</nav>
{seciliIl ? (
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 px-4 py-3">
<MapPin className="size-4 text-primary" aria-hidden />
<p className="text-sm font-medium">
Yalnızca <span className="font-bold">{seciliIl}</span>{" "}
programları gösteriliyor
</p>
<Button
asChild
size="sm"
variant="outline"
className="cursor-pointer"
>
<Link href={`/sonuc?sira=${sira}&tur=${turKey}`}>
<X className="size-3.5" aria-hidden />
Filtreyi kaldır
</Link>
</Button>
</div>
) : null}
{/* Bölge haritası */}
<section aria-labelledby="baslik-harita" className="mt-10">
<h2 id="baslik-harita" className="font-heading text-2xl font-bold">
Nerede okuyacaksın?
</h2>
<p className="mt-1 text-sm text-slate-600">
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.
</p>
<div className="mt-4 rounded-2xl border border-slate-200 bg-white p-4 sm:p-6">
<BolgeHaritasi
iller={iller}
sira={sira}
turKey={turKey}
seciliIl={seciliIl}
/>
</div>
</section>
<div className="mt-10 space-y-12">
{sections.map((s) => (
<section key={s.key} aria-labelledby={`baslik-${s.key}`}>

View File

@@ -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<string, IlOzeti>;
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<Secim | null>(null);
const kapsayici = useRef<HTMLDivElement>(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 (
<div ref={kapsayici} className="relative">
<svg
viewBox="0 0 1050 585"
className="h-auto w-full"
role="img"
aria-label="Önerilen programların illere dağılımı haritası"
onClick={(e) => {
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 (
<path
key={city.id}
d={city.path}
className={
ozet
? "cursor-pointer transition-opacity duration-200 hover:opacity-80"
: "fill-slate-200 stroke-white"
}
style={
ozet
? {
fill: `oklch(0.623 0.188 259.8 / ${0.3 + 0.7 * yogunluk})`,
stroke: secili ? "oklch(0.705 0.213 47.6)" : "white",
strokeWidth: secili ? 2.5 : 1,
}
: undefined
}
strokeWidth={1}
onClick={
ozet ? () => filtrele(key) : () => setSecim(null)
}
onMouseEnter={
ozet
? (e) => sec(e.currentTarget, city.name, ozet)
: undefined
}
onMouseLeave={ozet ? () => setSecim(null) : undefined}
>
<title>
{ozet
? `${city.name}: ${toplam(ozet)} program — filtrelemek için tıkla`
: city.name}
</title>
</path>
);
})}
</svg>
{secim ? (
<div
data-testid="harita-tooltip"
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-md"
style={{ left: secim.x, top: secim.y - 6 }}
>
<p className="font-heading font-semibold">{secim.name}</p>
<p className="text-slate-600">
{toplam(secim.ozet)} program
<span className="block text-xs">
{secim.ozet.hayal} hayal · {secim.ozet.dengeli} dengeli ·{" "}
{secim.ozet.garanti} garanti
</span>
<span className="block text-xs font-medium text-primary">
{normalizeIl(secim.name) === seciliIl
? "Filtreyi kaldırmak için tıkla"
: "Filtrelemek için tıkla"}
</span>
</p>
</div>
) : null}
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-slate-500">
<span>az</span>
<span
aria-hidden
className="h-2 w-24 rounded-full"
style={{
background:
"linear-gradient(to right, oklch(0.623 0.188 259.8 / 0.3), oklch(0.623 0.188 259.8))",
}}
/>
<span>çok program</span>
</div>
{haritaDisi.length > 0 ? (
<p className="mt-3 text-xs text-slate-500">
Harita dışı:{" "}
{haritaDisi
.map(
([il, ozet]) => `${il.toLocaleLowerCase("tr-TR")} (${toplam(ozet)})`
)
.join(", ")}
</p>
) : null}
</div>
);
}

View File

@@ -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