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:
@@ -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}`}>
|
||||
|
||||
182
src/components/bolge-haritasi.tsx
Normal file
182
src/components/bolge-haritasi.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user