Files
kolaytercih/src/components/tercih-haritasi.tsx
bilalgursen d93cf742ff
All checks were successful
Deploy / deploy (push) Successful in 2m38s
Update UI components and database schema
- Updated the `page.tsx` component to enhance user experience with new UI elements and improved layout.
- Modified the `funnel-banner.tsx` to include dynamic text and improved styling for better engagement.
- Adjusted the `liste-paneli.tsx` to provide clearer information on user credits and package details.
- Enhanced the `rapor-listesi.tsx` to display additional data points for better insights.
- Updated the database schema in `app.db` to reflect recent changes in the application logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 05:17:17 +03:00

417 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<RiskSeviyesi, string> = {
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<RiskSeviyesi, number> = {
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<string, HaritaPin[]>();
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<string, SVGPathElement>());
const [vb, setVb] = useState<ViewBox>(TAM_GORUNUM);
const vbRef = useRef<ViewBox>(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<string, HaritaPin[]>();
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 (
<div>
<div className="relative">
<svg
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
className="h-auto w-full"
role="img"
aria-label="Tercih listesinin Türkiye haritasındaki dağılımı"
>
{/* İl zeminleri */}
{cities.map((city) => {
const key = normalizeIlAdi(city.name);
const iceriyor = ilPinleri.has(key);
const secili = key === seciliIl;
return (
<path
key={city.id}
ref={(el) => {
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);
}}
>
<title>
{iceriyor
? `${city.name}: ${(ilPinleri.get(key) ?? []).reduce(
(a, p) => a + p.tercihSiralari.length,
0,
)} tercih — yakınlaşmak için tıkla`
: city.name}
</title>
</path>
);
})}
{/* Ü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 (
<g
key={il}
className="cursor-pointer"
onClick={() => onSeciliIlDegisti(il)}
role="button"
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
>
<circle
cx={cx}
cy={cy}
r={11}
fill={renk}
stroke="white"
strokeWidth={2}
/>
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fontWeight={700}
fill="white"
className="pointer-events-none select-none"
>
{adet}
</text>
</g>
);
})
: 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 (
<g
key={p.id}
className={onTercihSec && !p.kilitli ? "cursor-pointer" : undefined}
onClick={
onTercihSec && !p.kilitli
? () => onTercihSec(p.tercihSiralari[0])
: undefined
}
role={p.kilitli ? undefined : "button"}
aria-label={
p.kilitli
? "Kilitli tercih"
: `${etiket}: ${p.tercihSiralari.length} tercih`
}
>
<circle
cx={p.x}
cy={p.y}
r={r}
fill={p.kilitli ? "oklch(0.704 0.04 256.8)" : RISK_RENK[p.risk]}
stroke="white"
strokeWidth={2 * sf}
strokeDasharray={p.kilitli ? `${3 * sf} ${2.5 * sf}` : undefined}
/>
<text
x={p.x}
y={p.y}
textAnchor="middle"
dominantBaseline="central"
fontSize={9 * sf}
fontWeight={700}
fill="white"
className="pointer-events-none select-none"
>
{p.kilitli ? "?" : p.tercihSiralari.length}
</text>
{etiket ? (
<text
x={p.x}
y={p.y - r - 4 * sf}
textAnchor="middle"
fontSize={10.5 * sf}
fontWeight={600}
fill="oklch(0.279 0.041 260.0)" // slate-800
stroke="white"
strokeWidth={3 * sf}
paintOrder="stroke"
className="pointer-events-none select-none"
>
{etiket}
</text>
) : null}
</g>
);
})
: null}
</svg>
{zoomda ? (
<Button
size="sm"
variant="outline"
onClick={() => onSeciliIlDegisti(null)}
className="absolute left-3 top-3 cursor-pointer rounded-full bg-white/90 backdrop-blur"
>
<ArrowLeft className="size-4" aria-hidden />
Türkiye
</Button>
) : null}
</div>
{/* Seçili ilin tercih şeridi (yalnızca zoom'dayken) */}
{zoomda && seciliPinler.length > 0 ? (
<div className="mt-2">
<p className="px-1 pb-1 text-xs font-semibold text-slate-500">
{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin
</p>
<ul className="flex gap-2 overflow-x-auto pb-1">
{seciliPinler
.flatMap((p) =>
p.tercihSiralari.map((sira) => ({ pin: p, sira })),
)
.sort((a, b) => a.sira - b.sira)
.map(({ pin, sira }) => (
<li key={sira} className="shrink-0">
<button
type="button"
onClick={onTercihSec ? () => onTercihSec(sira) : undefined}
className="flex cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-white py-1 pl-1.5 pr-3 text-xs transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
>
<span
className="flex size-5 items-center justify-center rounded-full font-heading text-[10px] font-bold text-white"
style={{ backgroundColor: RISK_RENK[pin.risk] }}
>
{sira}
</span>
{pin.kilitli ? (
<span className="inline-flex items-center gap-1 text-slate-400">
<Lock className="size-3" aria-hidden />
Kilitli tercih
</span>
) : (
<span className="max-w-40 truncate font-medium text-slate-700">
{kisaUniAdi(pin.universite)}
</span>
)}
</button>
</li>
))}
</ul>
</div>
) : null}
</div>
);
}