All checks were successful
Deploy / deploy (push) Successful in 5m1s
- /universiteler/devlet ve /universiteler/vakif: şehir sayfası iskeleti, haritada bu türden üniversitesi olan her il turuncu (yakınlaşma yok), üç özet kartı, şehir çipleri (sayfa içi çapa), şehir şehir §4.15 satırları, öteki türe geçiş hapı; metadata + sitemap. - TurCipi tek kaynak: devlet/vakıf çipi tür sayfasına link (sonuç, listem, tadımlık, bölüm, şehir/tür satırları); üniversite sayfası tür hapı ve /universiteler başlıkları da link. UniSatirlari ortak dosyada: ad, tür ve il ayrı linkler. - Kompakt profilli satır (DESIGN.md §10.9): iri kupa kutusu + text-3xl/4xl sıra, text-xl/2xl başlık, 48 px buton; telefonda alt alta, lg'de tek satır. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Şehri temsil eden piksel harita (DESIGN.md §4.17) — hero'daki dot-matrix
|
||
* Türkiye'nin (turkey-pixel-map.tsx) şehir sayfası ve üniversite konum kartı
|
||
* için genellenmiş hâli. Aynı dil: iri kareler, altı farklı opaklık, kıyının
|
||
* altına seyrelen serpinti, imleç yaklaşınca turuncuya ısınan pikseller
|
||
* (usePixelHeat). Fark: ızgara 56 sütun (her il en az 2×2 kare; Bilal, 24
|
||
* Eyl 2026: "piksel detaylarını azalt"), serpinti yok, bulunulan ilin
|
||
* kareleri turuncu, hücrenin %88'i kadar iri ("C'deki gibi"), sırayla
|
||
* belirir ve nabız atar.
|
||
*
|
||
* Markup hero'daki gibi deterministik string olarak üretilir ve
|
||
* dangerouslySetInnerHTML ile basılır: ~1300 rect React elemanı olarak
|
||
* hydrate edilmez; server ve client aynı çıktıyı verir.
|
||
*
|
||
* Yakınlaşma (Bilal, 24 Eyl 2026: "ilin bulunduğu yere doğru harita büyüsün,
|
||
* sayfa yüklendikten sonra delay ile"): SSR tüm ülkeyi basar; yüklenince
|
||
* ODAK_GECIKME sonra viewBox, ilin çevresindeki kutuya doğru (en çok
|
||
* ODAK_ZOOM kat) güçlü ease-out ile kayar — kareler büyür, il kadraja
|
||
* gelir. Kutu kabın o anki oranında kurulur ve il `odakHiza` ile kabın
|
||
* istenen noktasına (ör. sağ orta, metnin boş bıraktığı yere) getirilir.
|
||
* Harita bir arka plan gibi kabı doldurur (`doldur`: slice); kesik kenar
|
||
* kalmasın diye çağıran kenarları mask ile eritir (Bilal: "canvas net bir
|
||
* şekilde kesiliyor, çok kötü"). Hareketi azalt açıkken kutuya doğrudan atlanır.
|
||
*/
|
||
|
||
import { useEffect, useRef } from "react";
|
||
import { animate } from "motion";
|
||
import { usePixelHeat } from "@/components/use-pixel-heat";
|
||
import { ilUlkeHaritasi, type UlkeKodu } from "@/lib/harita-ulkeler";
|
||
import {
|
||
IL_HUCRELERI,
|
||
IL_IZGARA,
|
||
ULKE_HUCRELERI,
|
||
type IzgaraHucre,
|
||
} from "@/lib/il-izgara-verisi";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
const { sutun: SUTUN, satir: SATIR, hucre: CELL } = IL_IZGARA;
|
||
const DOT = CELL * 0.74;
|
||
const INSET = (CELL - DOT) / 2;
|
||
// Vurgu kareleri iri ve dolu (C seçeneğinin hissi)
|
||
const DOT_VURGU = CELL * 0.88;
|
||
const INSET_VURGU = (CELL - DOT_VURGU) / 2;
|
||
const VIEW_W = SUTUN * CELL;
|
||
const VIEW_H = SATIR * CELL;
|
||
// Hero'nun ısı fırçası 640 birimlik viewBox'ta 34; burada aynı oran.
|
||
const ISI_YARICAPI = 34 * (VIEW_W / 640);
|
||
// Yakınlaşma: gecikme (s), süre (s), en çok büyütme, ilin çevresinde pay (hücre)
|
||
// Sıçramasız yay (Apple: damping 1): hızlı başlayan ease-out "sert"
|
||
// duruyordu (Bilal, 24 Eyl 2026: "geçişler sert"); yay sıfır hızla başlar,
|
||
// yumuşak ivmelenir ve sessizce oturur.
|
||
const ODAK_GECIKME = 0.6;
|
||
const ODAK_SURE = 1.6;
|
||
const ODAK_ZOOM = 2.4;
|
||
const ODAK_PAY = 2;
|
||
|
||
type Kutu = readonly [number, number, number, number];
|
||
const TAM_KUTU: Kutu = [0, 0, VIEW_W, VIEW_H];
|
||
/** Vurgunun viewBox'taki sınır kutusu [x0, y0, x1, y1] */
|
||
type Sinir = readonly [number, number, number, number];
|
||
|
||
/** Odak konumu: ilin kabın neresine geleceği (0–1, sol/üst = 0). */
|
||
export type OdakHiza = { readonly x: number; readonly y: number };
|
||
const ORTA: OdakHiza = { x: 0.5, y: 0.5 };
|
||
|
||
function sinirKutusu(hucreler: readonly IzgaraHucre[]): Sinir | null {
|
||
if (hucreler.length === 0) return null;
|
||
let q0 = Infinity, q1 = -Infinity, r0 = Infinity, r1 = -Infinity;
|
||
for (const [q, r] of hucreler) {
|
||
if (q < q0) q0 = q;
|
||
if (q > q1) q1 = q;
|
||
if (r < r0) r0 = r;
|
||
if (r > r1) r1 = r;
|
||
}
|
||
return [
|
||
(q0 - ODAK_PAY) * CELL,
|
||
(r0 - ODAK_PAY) * CELL,
|
||
(q1 + 1 + ODAK_PAY) * CELL,
|
||
(r1 + 1 + ODAK_PAY) * CELL,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Odak kutusu: kabın oranında (kesme olmasın), en çok `zoom` kat büyüten,
|
||
* ilin merkezini kabın (hiza.x, hiza.y) noktasına getiren viewBox. Harita
|
||
* dışına taşabilir; kenarlar çağıranın mask'ıyla eridiği için sorun olmaz.
|
||
*/
|
||
function odakKutusu(sinir: Sinir, oran: number, zoom: number, hiza: OdakHiza): Kutu {
|
||
const [x0, y0, x1, y1] = sinir;
|
||
let w = Math.max(x1 - x0, VIEW_W / zoom);
|
||
let h = w / oran;
|
||
if (h < y1 - y0) {
|
||
h = y1 - y0;
|
||
w = h * oran;
|
||
}
|
||
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
||
return [cx - w * hiza.x, cy - h * hiza.y, w, h];
|
||
}
|
||
|
||
const OPACITIES = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
|
||
const opacityFor = (x: number, y: number) =>
|
||
OPACITIES[(x * 7 + y * 11) % OPACITIES.length];
|
||
const VURGU_OPACITIES = [1, 0.9, 0.82, 0.95];
|
||
const vurguOpacityFor = (x: number, y: number) =>
|
||
VURGU_OPACITIES[(x * 3 + y * 5) % VURGU_OPACITIES.length];
|
||
|
||
const f = (n: number) => n.toFixed(2);
|
||
|
||
function zeminKare(q: number, r: number, op: number) {
|
||
return `<rect data-px data-cx="${f(q * CELL + CELL / 2)}" data-cy="${f(r * CELL + CELL / 2)}" data-op="${op}" x="${f(q * CELL + INSET)}" y="${f(r * CELL + INSET)}" width="${f(DOT)}" height="${f(DOT)}" rx="${f(CELL * 0.2)}" fill="currentColor" fill-opacity="${op}"/>`;
|
||
}
|
||
|
||
function vurguKare(q: number, r: number, i: number) {
|
||
return `<rect class="kt-piksel-belir fill-orange-500" style="animation-delay:${i * 35}ms" x="${f(q * CELL + INSET_VURGU)}" y="${f(r * CELL + INSET_VURGU)}" width="${f(DOT_VURGU)}" height="${f(DOT_VURGU)}" rx="${f(CELL * 0.26)}" fill-opacity="${vurguOpacityFor(q, r)}"/>`;
|
||
}
|
||
|
||
/**
|
||
* Zemin (mavi doku) + vurgu (turuncu) hücrelerinden statik SVG içi markup.
|
||
* `isaret`: ülke haritasında şehir merkezi — 2×2 hücrelik tek iri kare.
|
||
*/
|
||
function markupUret(
|
||
zemin: readonly IzgaraHucre[],
|
||
vurgu: readonly IzgaraHucre[],
|
||
isaret: IzgaraHucre | null,
|
||
): Harita {
|
||
const parts: string[] = [];
|
||
const vurguSet = new Set(vurgu.map(([q, r]) => `${q},${r}`));
|
||
for (const [q, r] of zemin) {
|
||
if (vurguSet.has(`${q},${r}`)) continue;
|
||
parts.push(zeminKare(q, r, opacityFor(q, r)));
|
||
}
|
||
// Vurgu: sırayla belirir (kt-piksel-belir), grup hâlinde nabız atar
|
||
parts.push(
|
||
`<g class="animate-pulse motion-reduce:animate-none" style="animation-duration:3.6s">`,
|
||
);
|
||
vurgu.forEach(([q, r], i) => parts.push(vurguKare(q, r, i)));
|
||
if (isaret) {
|
||
const [q, r] = isaret;
|
||
const boy = 2 * CELL - 2 * INSET_VURGU;
|
||
parts.push(
|
||
`<rect class="kt-piksel-belir fill-orange-500" x="${f(q * CELL + INSET_VURGU)}" y="${f(r * CELL + INSET_VURGU)}" width="${f(boy)}" height="${f(boy)}" rx="${f(CELL * 0.5)}"/>`,
|
||
);
|
||
}
|
||
parts.push("</g>");
|
||
// Odak: il kareleri; ülke haritasında şehir işareti (2×2) ya da ülkenin kendisi
|
||
const odak = isaret
|
||
? { sinir: sinirKutusu([isaret, [isaret[0] + 1, isaret[1] + 1]]), zoom: 1.8 }
|
||
: { sinir: sinirKutusu(vurgu), zoom: ODAK_ZOOM };
|
||
return { html: parts.join(""), ...odak };
|
||
}
|
||
|
||
type Harita = { html: string; sinir: Sinir | null; zoom: number };
|
||
|
||
const onbellek = new Map<string, Harita>();
|
||
function markup(anahtar: string, uret: () => Harita): Harita {
|
||
let m = onbellek.get(anahtar);
|
||
if (!m) {
|
||
m = uret();
|
||
onbellek.set(anahtar, m);
|
||
}
|
||
return m;
|
||
}
|
||
|
||
const kutuMetni = (k: Kutu) => k.map((n) => n.toFixed(1)).join(" ");
|
||
|
||
type HaritaProps = {
|
||
etiket: string;
|
||
className?: string;
|
||
/** Kabı doldur (preserveAspectRatio slice) — arka plan kullanımı */
|
||
doldur?: boolean;
|
||
/** İlin kabın neresine geleceği; `lg` geniş ekranda, `dar` altında */
|
||
odakHiza?: { lg: OdakHiza; dar: OdakHiza };
|
||
};
|
||
|
||
function PikselSvg({
|
||
harita,
|
||
etiket,
|
||
className,
|
||
doldur = false,
|
||
odakHiza,
|
||
}: HaritaProps & { harita: Harita }) {
|
||
const svgRef = useRef<SVGSVGElement>(null);
|
||
usePixelHeat(svgRef, { radius: ISI_YARICAPI });
|
||
|
||
// Yüklenince gecikmeyle odağa yakınlaş. viewBox her karede yazılır; ısı
|
||
// fırçası viewBox'ı canlı okuduğu için imleç hep doğru kareyi ısıtır.
|
||
const { sinir, zoom } = harita;
|
||
const hizaLg = odakHiza?.lg ?? ORTA;
|
||
const hizaDar = odakHiza?.dar ?? ORTA;
|
||
useEffect(() => {
|
||
const svg = svgRef.current;
|
||
if (!svg || !sinir) return;
|
||
const rect = svg.getBoundingClientRect();
|
||
const oran = rect.width && rect.height ? rect.width / rect.height : VIEW_W / VIEW_H;
|
||
const genis = window.matchMedia("(min-width: 1024px)").matches;
|
||
const odak = odakKutusu(sinir, oran, zoom, genis ? hizaLg : hizaDar);
|
||
const yaz = (k: Kutu) => svg.setAttribute("viewBox", kutuMetni(k));
|
||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||
yaz(odak);
|
||
return () => yaz(TAM_KUTU);
|
||
}
|
||
const bas = TAM_KUTU;
|
||
const kontrol = animate(0, 1, {
|
||
type: "spring",
|
||
bounce: 0,
|
||
delay: ODAK_GECIKME,
|
||
duration: ODAK_SURE,
|
||
onUpdate: (t) =>
|
||
yaz([
|
||
bas[0] + (odak[0] - bas[0]) * t,
|
||
bas[1] + (odak[1] - bas[1]) * t,
|
||
bas[2] + (odak[2] - bas[2]) * t,
|
||
bas[3] + (odak[3] - bas[3]) * t,
|
||
]),
|
||
});
|
||
return () => {
|
||
kontrol.stop();
|
||
yaz(TAM_KUTU);
|
||
};
|
||
}, [sinir, zoom, hizaLg, hizaDar]);
|
||
|
||
return (
|
||
<svg
|
||
ref={svgRef}
|
||
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||
preserveAspectRatio={doldur ? "xMidYMid slice" : "xMidYMid meet"}
|
||
className={cn(doldur ? "h-full w-full" : "h-auto w-full", "text-primary", className)}
|
||
role="img"
|
||
aria-label={etiket}
|
||
// Statik, deterministik markup — her render'da yeniden kurulmaz
|
||
dangerouslySetInnerHTML={{ __html: harita.html }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const TUM_IL_HUCRELERI = Object.values(IL_HUCRELERI).flat();
|
||
|
||
/**
|
||
* Türkiye; `il` (DB biçimi, ör. "İZMİR") turuncu karelerle yanar. `iller`
|
||
* verilirse hepsi yanar (tür sayfası: devlet üniversitesi olan her il) ve
|
||
* yakınlaşma yapılmaz — ülkenin tamamı görünür.
|
||
*/
|
||
export function TurkiyePikselHaritasi({
|
||
il,
|
||
iller,
|
||
...props
|
||
}: HaritaProps & { il?: string; iller?: readonly string[] }) {
|
||
const liste = iller ?? (il ? [il] : []);
|
||
const anahtar = `tr:${liste.join("|")}`;
|
||
const harita = markup(anahtar, () => {
|
||
const vurgu = liste.flatMap((ad) => IL_HUCRELERI[ad] ?? []);
|
||
const h = markupUret(TUM_IL_HUCRELERI, vurgu, null);
|
||
return iller ? { ...h, sinir: null } : h;
|
||
});
|
||
return <PikselSvg harita={harita} {...props} />;
|
||
}
|
||
|
||
/**
|
||
* Ülke (KKTC, yurt dışı). Şehir konumu verilirse ülke mavi kalır ve şehirde
|
||
* 2×2 hücrelik iri turuncu kare durur; verilmezse ülkenin tamamı turuncu.
|
||
*/
|
||
export function UlkePikselHaritasi({
|
||
ulke,
|
||
sehir,
|
||
...props
|
||
}: HaritaProps & {
|
||
ulke: UlkeKodu;
|
||
/** viewBox koordinatı (harita-ulkeler'deki proje ile) */
|
||
sehir: { x: number; y: number } | null;
|
||
}) {
|
||
const harita = markup(
|
||
`ulke:${ulke}:${sehir ? `${Math.round(sehir.x)},${Math.round(sehir.y)}` : "-"}`,
|
||
() => {
|
||
const h = ULKE_HUCRELERI[ulke];
|
||
if (sehir) {
|
||
const q = Math.min(SUTUN - 2, Math.max(0, Math.round(sehir.x / CELL) - 1));
|
||
const r = Math.min(SATIR - 2, Math.max(0, Math.round(sehir.y / CELL) - 1));
|
||
return markupUret([...h.baglam, ...h.ulke], [], [q, r]);
|
||
}
|
||
return markupUret(h.baglam, h.ulke, null);
|
||
},
|
||
);
|
||
return <PikselSvg harita={harita} {...props} />;
|
||
}
|
||
|
||
/** Şehir sayfası: il değerinden Türkiye ya da ülke haritasını seçer. */
|
||
export function SehirPikselHaritasi({
|
||
il,
|
||
ad,
|
||
...props
|
||
}: Omit<HaritaProps, "etiket"> & {
|
||
/** DB biçiminde il ("İSTANBUL", "LEFKOŞA", "KIBRIS") */
|
||
il: string;
|
||
/** Görünen ad (etiket için) */
|
||
ad: string;
|
||
}) {
|
||
if (IL_HUCRELERI[il]) {
|
||
return (
|
||
<TurkiyePikselHaritasi
|
||
il={il}
|
||
etiket={`${ad} Türkiye haritasında`}
|
||
{...props}
|
||
/>
|
||
);
|
||
}
|
||
const ulke = ilUlkeHaritasi(il);
|
||
if (!ulke) return null;
|
||
return (
|
||
<UlkePikselHaritasi
|
||
ulke={ulke.ulke}
|
||
sehir={ulke.sehir ? { x: ulke.sehir.x, y: ulke.sehir.y } : null}
|
||
etiket={`${ad} ${ulke.ulkeAd} haritasında`}
|
||
{...props}
|
||
/>
|
||
);
|
||
}
|