Update Next.js configuration and package dependencies
All checks were successful
Deploy / deploy (push) Successful in 9m29s
All checks were successful
Deploy / deploy (push) Successful in 9m29s
- Modified redirects in next.config.ts to change the destination for "/sohbet" to "/listem". - Added new dependencies in package.json: marked, motion, react-markdown, remark-breaks, remark-gfm, shiki, and use-stick-to-bottom. - Updated pnpm-lock.yaml to reflect the new package versions and dependencies. - Removed obsolete stack files for Flutter, Nuxt, Nuxt.js, React Native, and Svelte from the UI/UX Pro Max skill data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
417
src/components/tercih-haritasi.tsx
Normal file
417
src/components/tercih-haritasi.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
"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_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
|
||||
};
|
||||
|
||||
// SVG uzayı 0 0 1050 585 ama ülke çizimleri y≈144'te başlıyor; üstteki ölü
|
||||
// bandı kırpıyoruz ki harita sayfada gereksiz boşluk bırakmasın.
|
||||
const TAM_GORUNUM = {
|
||||
x: 0,
|
||||
y: 138,
|
||||
w: HARITA_GENISLIK,
|
||||
h: HARITA_YUKSEKLIK - 138,
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user