"use client"; // Sonuç sayfasının kahraman öğesi: 24 tercihin Türkiye haritası üzerinde // risk renkli pinlerle gösterimi. Ülke seviyesinde il başına tek toplu pin; // ile tıklayınca viewBox o ilin sınırlarına animasyonla akar, il büyür ve // üniversite pinleri gerçek kampüs konumlarında adlarıyla görünür. // Kilitli (paketsiz) tercihler il merkezinde adsız "?" pini olarak çizilir — // üniversite adı client'a hiç inmediği için paywall haritadan delinemez. import { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { ArrowLeft, Lock } from "lucide-react"; import { cities } from "turkey-map-react/lib/data"; import { Button } from "@/components/ui/button"; import { HARITA_GENISLIK, HARITA_UST_KIRPMA, HARITA_YUKSEKLIK, normalizeIlAdi, } from "@/lib/harita"; import type { RiskSeviyesi } from "@/lib/risk"; export type HaritaPin = { /** Üniversite bazlı benzersiz anahtar (kilitlilerde sentetik) */ id: string; /** Görünen ad — kilitli pinlerde boş bırakılır */ universite: string; /** DB biçiminde il adı (büyük harf); harita dışıysa null */ il: string | null; x: number; y: number; /** Bu üniversitedeki tercihlerin liste sıraları (1-24) */ tercihSiralari: number[]; risk: RiskSeviyesi; kilitli?: boolean; }; const RISK_RENK: Record = { guvenli: "oklch(0.696 0.17 162.5)", // emerald-500 "az-riskli": "oklch(0.769 0.188 70.1)", // amber-500 riskli: "oklch(0.637 0.237 25.3)", // red-500 }; const TAM_GORUNUM = { x: 0, y: HARITA_UST_KIRPMA, w: HARITA_GENISLIK, h: HARITA_YUKSEKLIK - HARITA_UST_KIRPMA, }; type ViewBox = typeof TAM_GORUNUM; /** "ORTA DOĞU TEKNİK ÜNİVERSİTESİ" → "Orta Doğu Teknik" */ function kisaUniAdi(ad: string): string { const kisa = ad .replace(/\s+(ÜNİVERSİTESİ|ENSTİTÜSÜ|MESLEK YÜKSEKOKULU)$/i, "") .trim(); return kisa .toLocaleLowerCase("tr-TR") .split(" ") .map((k) => k.length > 2 || k === "29" ? k.charAt(0).toLocaleUpperCase("tr-TR") + k.slice(1) : k, ) .join(" "); } /** İldeki pinlerin çoğunluk riski (eşitlikte güvenli taraf). */ function baskinRisk(pinler: HaritaPin[]): RiskSeviyesi { const sayac: Record = { guvenli: 0, "az-riskli": 0, riskli: 0, }; for (const p of pinler) sayac[p.risk] += p.tercihSiralari.length; return (["guvenli", "az-riskli", "riskli"] as const).reduce((a, b) => sayac[b] > sayac[a] ? b : a, ); } /** Aynı noktaya düşen pinleri küçük bir halka üzerinde ayrıştırır. */ function cakismaAc(pinler: HaritaPin[]): HaritaPin[] { const gruplar = new Map(); for (const p of pinler) { const key = `${Math.round(p.x)}:${Math.round(p.y)}`; const grup = gruplar.get(key); if (grup) grup.push(p); else gruplar.set(key, [p]); } const sonuc: HaritaPin[] = []; for (const grup of gruplar.values()) { if (grup.length === 1) { sonuc.push(grup[0]); continue; } grup.forEach((p, i) => { const aci = (2 * Math.PI * i) / grup.length; sonuc.push({ ...p, x: p.x + Math.cos(aci) * 5, y: p.y + Math.sin(aci) * 5 }); }); } return sonuc; } export function TercihHaritasi({ pinler, seciliIl, onSeciliIlDegisti, onTercihSec, }: { pinler: HaritaPin[]; /** Zoom'lanacak il (DB biçimi) — liste tarafından da kontrol edilebilir */ seciliIl: string | null; onSeciliIlDegisti: (il: string | null) => void; /** Alt şeritte bir tercihe dokunulunca (liste satırına in) */ onTercihSec?: (sira: number) => void; }) { const pathRefs = useRef(new Map()); const [vb, setVb] = useState(TAM_GORUNUM); const vbRef = useRef(TAM_GORUNUM); const animRef = useRef(0); const animateTo = useCallback((hedef: ViewBox) => { cancelAnimationFrame(animRef.current); const kaynak = { ...vbRef.current }; const t0 = performance.now(); const SURE = 380; const ease = (t: number) => 1 - Math.pow(1 - t, 3); const adim = (now: number) => { const t = Math.min(1, (now - t0) / SURE); const k = ease(t); const sonraki = { x: kaynak.x + (hedef.x - kaynak.x) * k, y: kaynak.y + (hedef.y - kaynak.y) * k, w: kaynak.w + (hedef.w - kaynak.w) * k, h: kaynak.h + (hedef.h - kaynak.h) * k, }; vbRef.current = sonraki; setVb(sonraki); if (t < 1) animRef.current = requestAnimationFrame(adim); }; animRef.current = requestAnimationFrame(adim); }, []); useEffect(() => () => cancelAnimationFrame(animRef.current), []); // seciliIl değişince ilgili ilin bbox'ına (veya tam görünüme) ak useEffect(() => { if (!seciliIl) { animateTo(TAM_GORUNUM); return; } const path = pathRefs.current.get(seciliIl); if (!path) return; const b = path.getBBox(); const pad = Math.max(b.width, b.height) * 0.22; let x = b.x - pad; let y = b.y - pad; let w = b.width + pad * 2; let h = b.height + pad * 2; // viewBox oranını koru ki il yamulmadan büyüsün ve harita yüksekliği // zoom'da değişmesin (tam görünümle aynı oran) const oran = TAM_GORUNUM.w / TAM_GORUNUM.h; if (w / h > oran) { const yeniH = w / oran; y -= (yeniH - h) / 2; h = yeniH; } else { const yeniW = h * oran; x -= (yeniW - w) / 2; w = yeniW; } animateTo({ x, y, w, h }); }, [seciliIl, animateTo]); const ilPinleri = useMemo(() => { const gruplar = new Map(); for (const p of pinler) { if (!p.il) continue; const grup = gruplar.get(p.il); if (grup) grup.push(p); else gruplar.set(p.il, [p]); } return gruplar; }, [pinler]); // Ekran boyutu sabit kalsın diye SVG içi ölçüler zoom oranıyla çarpılır const sf = vb.w / HARITA_GENISLIK; const zoomda = Boolean(seciliIl); const seciliPinler = seciliIl ? (ilPinleri.get(seciliIl) ?? []) : []; const acikPinler = cakismaAc(seciliPinler); return (
{/* İl zeminleri */} {cities.map((city) => { const key = normalizeIlAdi(city.name); const iceriyor = ilPinleri.has(key); const secili = key === seciliIl; return ( { if (el) pathRefs.current.set(key, el); else pathRefs.current.delete(key); }} d={city.path} className={ iceriyor ? "cursor-pointer transition-opacity duration-200 hover:opacity-80" : undefined } fill={ secili ? "oklch(0.97 0.014 254.6)" // blue-50: seçili zemin : iceriyor ? "oklch(0.882 0.059 254.1)" // blue-200 : "oklch(0.929 0.013 255.5)" // slate-200 } stroke="white" strokeWidth={secili ? 1.6 * sf : 1 * sf} onClick={() => { if (secili) onSeciliIlDegisti(null); else if (iceriyor) onSeciliIlDegisti(key); }} > {iceriyor ? `${city.name}: ${(ilPinleri.get(key) ?? []).reduce( (a, p) => a + p.tercihSiralari.length, 0, )} tercih — yakınlaşmak için tıkla` : city.name} ); })} {/* Ülke seviyesi: il başına toplu pin */} {!zoomda ? [...ilPinleri.entries()].map(([il, iller]) => { const adet = iller.reduce( (a, p) => a + p.tercihSiralari.length, 0, ); const cx = iller.reduce((a, p) => a + p.x, 0) / iller.length; const cy = iller.reduce((a, p) => a + p.y, 0) / iller.length; const renk = RISK_RENK[baskinRisk(iller)]; return ( onSeciliIlDegisti(il)} role="button" aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`} > {adet} ); }) : null} {/* İl seviyesi: üniversite pinleri */} {zoomda ? acikPinler.map((p) => { const r = (p.kilitli ? 7 : 8) * sf; const etiket = p.kilitli ? null : kisaUniAdi(p.universite); return ( onTercihSec(p.tercihSiralari[0]) : undefined } role={p.kilitli ? undefined : "button"} aria-label={ p.kilitli ? "Kilitli tercih" : `${etiket}: ${p.tercihSiralari.length} tercih` } > {p.kilitli ? "?" : p.tercihSiralari.length} {etiket ? ( {etiket} ) : null} ); }) : null} {zoomda ? ( ) : null}
{/* Seçili ilin tercih şeridi (yalnızca zoom'dayken) */} {zoomda && seciliPinler.length > 0 ? (

{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin

    {seciliPinler .flatMap((p) => p.tercihSiralari.map((sira) => ({ pin: p, sira })), ) .sort((a, b) => a.sira - b.sira) .map(({ pin, sira }) => (
  • ))}
) : null}
); }