feat(tasarim): şehir sayfasına hero dilinde piksel harita; üniversite konum kartı da aynı harita
All checks were successful
Deploy / deploy (push) Successful in 4m39s

- scripts/il-izgara-uret.ts → src/lib/il-izgara-verisi.ts: 81 il ve 7 ülke
  kontürü hero'nun dot-matrix ızgarasına (72×31) rasterize; küçük iller 2×2 garanti.
- SehirPikselHaritasi: iri kareler, altı opaklık, kıyı serpintisi, usePixelHeat
  ısı fırçası; bulunulan il iri turuncu karelerle sırayla belirir (kt-piksel-belir)
  ve nabız atar; KKTC/yurt dışında şehir merkezinde 2×2 iri kare.
- Şehir sayfası başlığı beyaz piksel dokulu yüzeyde (SehirYuzeyi), harita sağda/altta.
- UniversiteKonumHaritasi düz SVG yerine aynı piksel haritayı kullanır.
- DESIGN.md §4.12/§4.17/§10.8 güncellendi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-24 14:56:11 +03:00
parent 259534fa9c
commit eefac56659
8 changed files with 671 additions and 108 deletions

View File

@@ -20,6 +20,8 @@ import {
import { BASLIK_YILI, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
import { NavBaslik } from "./nav-baslik";
import { SehirCipleri, oneCikanIller } from "./sehir-cipleri";
import { SehirPikselHaritasi } from "./sehir-piksel-haritasi";
import { SehirYuzeyi } from "./sehir-yuzeyi";
import { UniLinkleri } from "./universite-listesi";
const sayi = (n: number) => n.toLocaleString("tr-TR");
@@ -123,39 +125,49 @@ export function IlIcerik({ slug }: { slug: string }) {
</nav>
<NavBaslik baslikId="il-baslik" ad={`${il.ad} üniversiteleri`} />
<h1
id="il-baslik"
className="mt-6 text-center font-heading text-3xl font-bold leading-tight text-slate-900 sm:text-5xl lg:text-left"
>
{il.ad} Üniversiteleri Taban Puanları {BASLIK_YILI}
</h1>
{/* Başlık yüzeyi: beyaz kart + slate piksel doku, şehrin dot-matrix
haritası (DESIGN.md §4.17). lg altında metin ortalı, harita altta. */}
<SehirYuzeyi
seed={31}
harita={<SehirPikselHaritasi il={il.il} ad={il.ad} />}
metin={
<div className="text-center lg:text-left">
<h1
id="il-baslik"
className="font-heading text-3xl font-bold leading-tight text-slate-900 sm:text-5xl"
>
{il.ad} Üniversiteleri Taban Puanları {BASLIK_YILI}
</h1>
{/* Kimlik hapları (DESIGN.md §4.12): il + üniversite sayısı */}
<p className="mt-5 flex flex-wrap items-center justify-center gap-2 lg:justify-start">
<span className="inline-flex h-10 items-center gap-2 rounded-full border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-800">
<MapPin className="size-4 text-primary" aria-hidden />
{il.ad}
</span>
<span className="inline-flex h-10 items-center gap-2 rounded-full border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-800">
{devlet.length >= vakif.length ? (
<Landmark className="size-4 text-primary" aria-hidden />
) : (
<Building2 className="size-4 text-primary" aria-hidden />
)}
{sayi(il.uniler.length)} üniversite
</span>
</p>
{/* Kimlik hapları (DESIGN.md §4.12): il + üniversite sayısı */}
<p className="mt-5 flex flex-wrap items-center justify-center gap-2 lg:justify-start">
<span className="inline-flex h-10 items-center gap-2 rounded-full border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-800">
<MapPin className="size-4 text-primary" aria-hidden />
{il.ad}
</span>
<span className="inline-flex h-10 items-center gap-2 rounded-full border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-800">
{devlet.length >= vakif.length ? (
<Landmark className="size-4 text-primary" aria-hidden />
) : (
<Building2 className="size-4 text-primary" aria-hidden />
)}
{sayi(il.uniler.length)} üniversite
</span>
</p>
<p className="mx-auto mt-6 max-w-3xl text-center text-base leading-relaxed text-slate-600 sm:text-lg lg:mx-0 lg:text-left">
{il.ad}&apos;da {SON_YIL} YKS yerleştirmesinde öğrenci alan{" "}
<strong>{sayi(il.uniler.length)} üniversite</strong>
{ozetParcalar.length > 1 ? ` (${ozetParcalar.join(", ")})` : ""} ve
toplam <strong>{sayi(il.programSayisi)} program</strong> var.
Üniversiteye tıklayınca programlarının taban puanlarını, başarı
sıralamalarını ve kontenjanlarını fakülte fakülte görürsün. Veriler
YÖK Atlas {SON_YIL} yerleştirme sonuçlarına dayanır; {TERCIH_YILI}{" "}
tercihleri için rehber niteliğindedir.
</p>
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-slate-600 sm:text-lg lg:mx-0">
{il.ad}&apos;da {SON_YIL} YKS yerleştirmesinde öğrenci alan{" "}
<strong>{sayi(il.uniler.length)} üniversite</strong>
{ozetParcalar.length > 1 ? ` (${ozetParcalar.join(", ")})` : ""}{" "}
ve toplam <strong>{sayi(il.programSayisi)} program</strong> var.
Üniversiteye tıklayınca programlarının taban puanlarını, başarı
sıralamalarını ve kontenjanlarını fakülte fakülte görürsün.
Veriler YÖK Atlas {SON_YIL} yerleştirme sonuçlarına dayanır;{" "}
{TERCIH_YILI} tercihleri için rehber niteliğindedir.
</p>
</div>
}
/>
<div className="mt-8">
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
@@ -196,12 +208,7 @@ export function IlIcerikSkeleton() {
return (
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16">
<Skeleton className="mx-auto h-4 w-64 lg:mx-0" />
<Skeleton className="mx-auto mt-6 h-10 w-full max-w-xl lg:mx-0" />
<div className="mt-5 flex justify-center gap-2 lg:justify-start">
<Skeleton className="h-10 w-28 rounded-full" />
<Skeleton className="h-10 w-32 rounded-full" />
</div>
<Skeleton className="mx-auto mt-6 h-5 w-full max-w-3xl lg:mx-0" />
<Skeleton className="mt-6 h-72 w-full rounded-3xl" />
<div className="mt-10 space-y-4">
<Skeleton className="h-8 w-72" />
{Array.from({ length: 6 }, (_, i) => (

View File

@@ -0,0 +1,225 @@
"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 72 sütun (her il en az 2×2 kare) ve
* bulunulan ilin kareleri turuncu, hücrenin %88'i kadar iri (Bilal, 24 Eyl
* 2026: "C'deki gibi piksel olabilir"), 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.
*/
import { useRef } from "react";
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;
// Güney kıyının altına seyrelerek dağılan serpinti derinliği (hücre)
const SERPINTI = 5;
const VIEW_W = SUTUN * CELL;
const VIEW_H = (SATIR + SERPINTI) * CELL;
// Hero'nun ısı fırçası 640 birimlik viewBox'ta 34; burada aynı oran.
const ISI_YARICAPI = 34 * (VIEW_W / 640);
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];
// Deterministik sözde-rastgele (hero ile aynı): server ve client aynı deseni üretir
const hash = (x: number, y: number, seed = 0) => {
const n = Math.sin(x * 127.1 + y * 311.7 + seed * 74.7) * 43758.5453;
return n - Math.floor(n);
};
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,
): string {
const parts: string[] = [];
const vurguSet = new Set(vurgu.map(([q, r]) => `${q},${r}`));
const enAlt: number[] = Array(SUTUN).fill(-1);
for (const [q, r] of zemin) {
if (vurguSet.has(`${q},${r}`)) continue;
parts.push(zeminKare(q, r, opacityFor(q, r)));
if (r > enAlt[q]) enAlt[q] = r;
}
for (const [q, r] of vurgu) if (r > enAlt[q]) enAlt[q] = r;
// Kıyının altına serpinti (hero ile aynı kural): kıyıdan uzaklaştıkça
// seyrelen, derinlikle sönen kareler; ısı fırçasına katılırlar.
for (let q = 0; q < SUTUN; q++) {
if (enAlt[q] < 0) continue;
for (let d = 1; d <= SERPINTI; d++) {
const r = enAlt[q] + d;
if (r >= SATIR + SERPINTI) break;
const density = 0.05 + 0.4 * (1 - (d - 1) / SERPINTI) ** 2;
if (hash(q, r) > density) continue;
const op = Math.round(opacityFor(q, r) * (1 - d / (SERPINTI + 1)) * 100) / 100;
parts.push(zeminKare(q, r, op));
}
}
// 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>");
return parts.join("");
}
const onbellek = new Map<string, string>();
function markup(anahtar: string, uret: () => string): string {
let m = onbellek.get(anahtar);
if (!m) {
m = uret();
onbellek.set(anahtar, m);
}
return m;
}
function PikselSvg({
html,
etiket,
className,
}: {
html: string;
etiket: string;
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef, { radius: ISI_YARICAPI });
return (
<svg
ref={svgRef}
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className={cn("h-auto w-full text-primary", className)}
role="img"
aria-label={etiket}
// Statik, deterministik markup — her render'da yeniden kurulmaz
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
const TUM_IL_HUCRELERI = Object.values(IL_HUCRELERI).flat();
/** Türkiye; `il` (DB biçimi, ör. "İZMİR") turuncu karelerle yanar. */
export function TurkiyePikselHaritasi({
il,
etiket,
className,
}: {
il: string;
etiket: string;
className?: string;
}) {
const html = markup(`tr:${il}`, () =>
markupUret(TUM_IL_HUCRELERI, IL_HUCRELERI[il] ?? [], null),
);
return <PikselSvg html={html} etiket={etiket} className={className} />;
}
/**
* Ü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,
etiket,
className,
}: {
ulke: UlkeKodu;
/** viewBox koordinatı (harita-ulkeler'deki proje ile) */
sehir: { x: number; y: number } | null;
etiket: string;
className?: string;
}) {
const html = 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 html={html} etiket={etiket} className={className} />;
}
/** Şehir sayfası: il değerinden Türkiye ya da ülke haritasını seçer. */
export function SehirPikselHaritasi({
il,
ad,
className,
}: {
/** DB biçiminde il ("İSTANBUL", "LEFKOŞA", "KIBRIS") */
il: string;
/** Görünen ad (etiket için) */
ad: string;
className?: string;
}) {
if (IL_HUCRELERI[il]) {
return (
<TurkiyePikselHaritasi
il={il}
etiket={`${ad} Türkiye haritasında`}
className={className}
/>
);
}
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`}
className={className}
/>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { useRef, type ReactNode } from "react";
import { PikselZemin } from "@/components/piksel-zemin";
/**
* Şehir sayfasının başlık yüzeyi (DESIGN.md §4.17): beyaz kart, slate
* piksel doku, sağda (lg) ya da altta (telefon) şehrin dot-matrix haritası.
* Doku ile harita üst üste binmez: harita kabı `haric` ile zemine verilir
* (§10.8 kuralı). Yalnız ref için client; metin ve harita sunucudan gelir.
*/
export function SehirYuzeyi({
metin,
harita,
seed = 31,
}: {
metin: ReactNode;
harita: ReactNode;
seed?: number;
}) {
const haritaRef = useRef<HTMLDivElement>(null);
return (
<section className="relative isolate mt-6 overflow-hidden rounded-3xl border border-slate-200 bg-white p-6 sm:p-8">
<PikselZemin ton="acik" seed={seed} haric={haritaRef} />
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,24rem)] lg:items-center lg:gap-10">
<div>{metin}</div>
{harita ? (
<div ref={haritaRef} className="mx-auto w-full max-w-md lg:max-w-none">
{harita}
</div>
) : null}
</div>
</section>
);
}

View File

@@ -1,20 +1,20 @@
import { normalizeIlAdi } from "@/lib/harita";
import { uniUlkeKonum, yurtdisiUniKonum } from "@/lib/harita-ulkeler";
import { cities } from "turkey-map-react/lib/data";
import {
HARITA_GENISLIK,
HARITA_UST_KIRPMA,
HARITA_YUKSEKLIK,
normalizeIlAdi,
} from "@/lib/harita";
import {
ULKE_HARITA_GENISLIK,
ULKE_HARITA_YUKSEKLIK,
uniUlkeKonum,
type UlkeKonum,
} from "@/lib/harita-ulkeler";
TurkiyePikselHaritasi,
UlkePikselHaritasi,
} from "./sehir-piksel-haritasi";
const SECILI_DOLGU = "oklch(0.809 0.105 251.8)";
const ZEMIN_DOLGU = "oklch(0.929 0.013 255.5)";
// Harita il adları modül yükünde bir kez DB biçimine çevrilir.
const HARITA_ILLERI = new Set(cities.map((c) => normalizeIlAdi(c.name)));
/**
* Üniversite sayfasındaki konum kartı: şehir sayfasıyla aynı dot-matrix
* harita (DESIGN.md §4.17; Bilal, 24 Eyl 2026: "bu kart kısmına piksel
* dokuları ekle"). Türkiye'de il turuncu noktalarla yanar; KKTC/yurt dışında
* ülke kontürü + kampüs şehrinde iri turuncu piksel.
*/
export function UniversiteKonumHaritasi({
universite,
il,
@@ -24,9 +24,15 @@ export function UniversiteKonumHaritasi({
}) {
const yurtdisi = uniUlkeKonum(universite, il);
if (yurtdisi) {
const konum = yurtdisiUniKonum(universite, il);
return (
<KonumBolumu universite={universite}>
<UlkeHaritasi universite={universite} konum={yurtdisi} />
<UlkePikselHaritasi
ulke={yurtdisi.ulke}
sehir={konum ? { x: konum.x, y: konum.y } : null}
etiket={`${universite} konumunun ${yurtdisi.ulkeAd} haritasındaki görünümü${yurtdisi.sehir ? ` (${yurtdisi.sehir})` : ""}`}
className="absolute inset-0 h-full w-full"
/>
<p className="pointer-events-none absolute bottom-0 left-0 text-xs font-medium text-slate-500">
{yurtdisi.ulkeAd}
{yurtdisi.sehir ? ` · ${yurtdisi.sehir}` : ""}
@@ -35,31 +41,15 @@ export function UniversiteKonumHaritasi({
);
}
if (!il) return null;
if (!il || !HARITA_ILLERI.has(il)) return null;
return (
<KonumBolumu universite={universite}>
<svg
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`}
<TurkiyePikselHaritasi
il={il}
etiket={`${universite} konumunun Türkiye haritasındaki görünümü`}
className="absolute inset-0 h-full w-full"
role="img"
aria-label={`${universite} konumunun Türkiye haritasındaki görünümü`}
>
{cities.map((city) => {
const secili = il ? normalizeIlAdi(city.name) === il : false;
return (
<path
key={city.id}
d={city.path}
fill={secili ? SECILI_DOLGU : ZEMIN_DOLGU}
stroke="white"
strokeWidth={secili ? 1.8 : 1}
>
<title>{city.name}</title>
</path>
);
})}
</svg>
/>
</KonumBolumu>
);
}
@@ -83,30 +73,3 @@ function KonumBolumu({
</section>
);
}
function UlkeHaritasi({
universite,
konum,
}: {
universite: string;
konum: UlkeKonum;
}) {
const { harita, ulkeAd, sehir } = konum;
return (
<svg
viewBox={`0 0 ${ULKE_HARITA_GENISLIK} ${ULKE_HARITA_YUKSEKLIK}`}
className="absolute inset-0 h-full w-full"
role="img"
aria-label={`${universite} konumunun ${ulkeAd} haritasındaki görünümü${sehir ? ` (${sehir})` : ""}`}
>
{harita.baglam.map((d, i) => (
<path key={`b${i}`} d={d} fill={ZEMIN_DOLGU} stroke="white" strokeWidth={1} />
))}
{harita.ulke.map((d, i) => (
<path key={`u${i}`} d={d} fill={SECILI_DOLGU} stroke="white" strokeWidth={1.8}>
<title>{ulkeAd}</title>
</path>
))}
</svg>
);
}