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

@@ -352,7 +352,13 @@ aşağı kaymış, kare boyları farklı — ne tepeler ne bitişler hizalı.**
Başlığın altında kurumun kimliği; navbar haplarının ölçüsünde: Başlığın altında kurumun kimliği; navbar haplarının ölçüsünde:
`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` `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`
+ başta `size-4 text-primary` ikon (`Landmark` devlet, `Building2` vakıf, `MapPin` il). Tür + başta `size-4 text-primary` ikon (`Landmark` devlet, `Building2` vakıf, `MapPin` il). Tür
renkle değil ikonla ayrılır. Etkileşimli değildir; rozet (§4.2) değildir. renkle değil ikonla ayrılır. Rozet (§4.2) değildir. Tür hapı etkileşimsizdir; **il hapı şehir
sayfasına link**tir (Bilal, 24 Eyl 2026: "şehirlerin geçtiği her yer tıklanabilir") — aynı
sınıf + `hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:scale-[0.97]`.
Kural genel: bir şehir adı nerede geçerse geçsin (`/universiteler` listesi, bölüm/sonuç/listem/
tadımlık satırlarındaki tür-il-süre metası) `ilSayfaHref(il)` ile `/universiteler/[il]`'e
gider; satır metasında `SATIR_IKINCIL_LINK`. İstisna: satırın tamamı zaten link olan yerler
(URAP sıralaması, arama sonucu) — iç içe `<a>` olmaz, orada düz metin kalır.
### 4.13 Piksel sıra şeridi — `PixelSira`, `src/components/pixel-decor.tsx` ### 4.13 Piksel sıra şeridi — `PixelSira`, `src/components/pixel-decor.tsx`
Bir sıranın (ör. 198 içinde 5.) yerini tek satır piksel şeridinde gösterir; sıranın düştüğü Bir sıranın (ör. 198 içinde 5.) yerini tek satır piksel şeridinde gösterir; sıranın düştüğü
@@ -441,11 +447,29 @@ bulunulan şehir `border-primary bg-primary text-white` + `aria-current="page"`.
- Çip satırında yalnız **2+ üniversiteli** şehirler (≈28) durur; 90'ı aşkın şehrin tamamı - Çip satırında yalnız **2+ üniversiteli** şehirler (≈28) durur; 90'ı aşkın şehrin tamamı
`/universiteler#tum-sehirler` altındaki A–Z üç sütunlu metin listesindedir (§1.1 "sık `/universiteler#tum-sehirler` altındaki A–Z üç sütunlu metin listesindedir (§1.1 "sık
bakılan önde, ayrıntı bir dokunuş derinde"). Satırın sonunda "Tüm şehirler (N)" çipi. bakılan önde, ayrıntı bir dokunuş derinde"). Satırın sonunda "Tüm şehirler (N)" çipi.
- Şehir sayfası (`il-icerik.tsx`) katalog detay düzenindedir (§5.2): yol satırı, h1 - Şehir sayfası (`il-icerik.tsx`) katalog detay düzenindedir (§5.2): yol satırı, **başlık
yüzeyi** (`SehirYuzeyi`: `relative isolate overflow-hidden rounded-3xl border border-slate-200
bg-white p-6 sm:p-8`, `PikselZemin ton="acik"` seed 31, harita kabı `haric`), içinde h1
"İstanbul Üniversiteleri Taban Puanları 2026", kimlik hapları (`MapPin` il + üniversite "İstanbul Üniversiteleri Taban Puanları 2026", kimlik hapları (`MapPin` il + üniversite
sayısı), özet paragraf, kompakt `CtaSiraForm`, `PagePixelDivider` (seed 71), devlet / sayısı), özet paragraf; sağda (lg, 24rem sütun) ya da altta (telefon) **şehri temsil eden
vakıf / KKTC-yurt dışı öbekleri (`UniLinkleri`), altta "Diğer şehirler" çipleri ve CTA piksel harita** `SehirPikselHaritasi`. Sonra kompakt `CtaSiraForm`, `PagePixelDivider`
bandı. Şehir adı `trBaslikDuzeni` + tire sonrası baş harf (Bakü-Azerbaycan). (seed 71), devlet / vakıf / KKTC-yurt dışı öbekleri (`UniLinkleri`), "Diğer şehirler"
çipleri ve mavi CTA bandı. Şehir adı `trBaslikDuzeni` + tire sonrası baş harf
(Bakü-Azerbaycan). Bir üniversite programı olan her ilde sayılır (ODTÜ Ankara + KKTC).
- **Şehir piksel haritası** — `sehir-piksel-haritasi.tsx`: **hero'daki dot-matrix Türkiye'nin
kendisi** (Bilal, 24 Eyl 2026: ince taramalı ilk sürüm "oyunsu Apple/Duolingo hissine
uymuyor" diye geri alındı; seçenekler A/B/C arasından "A, pikseller C'deki gibi iri"
seçildi). Izgara 72 sütun × 31 satır (`scripts/il-izgara-uret.ts` →
`src/lib/il-izgara-verisi.ts`; her il en az 2×2 kare), kare hücrenin %74'ü, altı farklı
opaklık, kıyının altına seyrelen serpinti, `text-primary` zemin, imleç yaklaşınca turuncuya
ısınma (`usePixelHeat`). Bulunulan ilin kareleri `fill-orange-500`, hücrenin %88'i kadar
iri, 35 ms arayla belirir (`kt-piksel-belir`) ve grup hâlinde nabız atar (`animate-pulse`
3.6 s); hareketi azalt açıkken dolu ve durağan. KKTC/yurt dışı: ülke kontürü mavi doku,
şehir merkezinde 2×2 hücrelik tek iri turuncu kare; şehri bilinmeyen "KIBRIS" için ülkenin
tamamı turuncu. Markup hero'daki gibi deterministik string (`dangerouslySetInnerHTML`).
`role="img"` + "İstanbul Türkiye haritasında". Üniversite sayfasının konum kartı
(`universite-konum-haritasi.tsx`) aynı bileşeni kullanır. Veri taşıyan pikseldir (§1.2),
süs ayraç yasağına girmez.
## 5. Yerleşim ## 5. Yerleşim
@@ -726,11 +750,17 @@ karar.
sm:py-5` + ilk çocuk `PikselZemin`), kenarlık saydam kalır ki iki hâlin kutusu aynı olsun. sm:py-5` + ilk çocuk `PikselZemin`), kenarlık saydam kalır ki iki hâlin kutusu aynı olsun.
Aynı sayfadaki iki band farklı `seed` alır. Aynı sayfadaki iki band farklı `seed` alır.
**Piksel zemin yalnız CTA yüzeylerinde — `PikselZemin`, `src/components/piksel-zemin.tsx`** **Piksel zemin yalnız CTA ve başlık yüzeylerinde — `PikselZemin`, `src/components/piksel-zemin.tsx`**
(Bilal, 24 Eyl 2026: önce "her CTA benzeri kısmın arkasında olmalı", sonra "/sonuc'taki kartın (Bilal, 24 Eyl 2026: önce "her CTA benzeri kısmın arkasında olmalı", sonra "/sonuc'taki kartın
arkasına da piksel koymuşsun; pikseller CTA'larda olacak sadece"). CTA yüzeyi = renkli zeminli arkasına da piksel koymuşsun; pikseller CTA'larda olacak sadece"). CTA yüzeyi = çağrı yüzeyi:
çağrı yüzeyi: mavi `CtaBandi`, koyu kapanış kartı `PikselCtaYuzeyi`, arama penceresindeki koyu mavi `CtaBandi`, koyu kapanış kartı `PikselCtaYuzeyi`, arama penceresindeki koyu kart.
kart. Beyaz içerik kartlarına (tadımlık, `/listem` üretim/kilit kartları, paket kartı, kaydet **Ton ikidir:** `ton="koyu"` (renkli zemin, beyaz kareler) ve `ton="acik"` (beyaz zemin,
`text-slate-400` kareler; turuncu vurgu kareleri aynı). Bilal (24 Eyl 2026): "her kısımda mavi
arka kullanmışsın, çeşitlendir; beyaz arka planda piksel de güzel görünüyor" — bu yüzden
`CtaBandi ton="beyaz"` (slate kenarlık, koyu yazı, paragraf `text-slate-600`) var ve aynı
sayfada iki bant üst üste gelmesin diye dönüşümlü kullanılır: `/universiteler` altı ve
`/meraklisina` kapanışı beyaz; şehir sayfası altı, `/sonuc` panelleri, çekmece altı mavi.
Şehir sayfasının başlık yüzeyi (§4.17) de açık tonlu piksel zemindir. Beyaz içerik kartlarına (tadımlık, `/listem` üretim/kilit kartları, paket kartı, kaydet
bandı, katalog üstü kompakt satırın **form** hâli, boş durumlar, sıralama-yok sayfası) bandı, katalog üstü kompakt satırın **form** hâli, boş durumlar, sıralama-yok sayfası)
**konmaz** — 24 Eyl'de denendi ve geri alındı. Aynı kompakt satırın kayıtlı sıralamalı hâli **konmaz** — 24 Eyl'de denendi ve geri alındı. Aynı kompakt satırın kayıtlı sıralamalı hâli
ise formsuz tek butonlu bir çağrı olduğundan mavi banttır ve dokuyu alır (yukarıda). Kap `relative isolate overflow-hidden`; zemin ilk çocuk; kareler beyaz. ise formsuz tek butonlu bir çağrı olduğundan mavi banttır ve dokuyu alır (yukarıda). Kap `relative isolate overflow-hidden`; zemin ilk çocuk; kareler beyaz.

170
scripts/il-izgara-uret.ts Normal file
View File

@@ -0,0 +1,170 @@
// Şehir haritası (DESIGN.md §4.17) için il ve ülke kontürlerini hero'nun
// dot-matrix ızgarasına rasterize eder. Hero'daki TurkeyPixelMap 64 sütun;
// burada 72 sütun ki her il en az birkaç kare alsın (Yalova, Kilis gibi
// küçük iller için 2×2 blok garantisi vardır). Çıktı
// src/lib/il-izgara-verisi.ts dosyasına yazılır ve commit'lenir.
//
// Kaynak: turkey-map-react il path'leri (viewBox 1050×585, üstten 138 kırpık)
// ve harita-ulkeler-verisi.ts ülke kontürleri (1050×447). Hücre merkezi
// poligonun içindeyse hücre o ile aittir (ray casting; Bézier eğrileri uç
// noktalarıyla düzleştirilir — hücre ölçeğinde fark etmez).
//
// Çalıştırma: pnpm tsx scripts/il-izgara-uret.ts
import fs from "node:fs";
import path from "node:path";
import { cities } from "turkey-map-react/lib/data";
import { normalizeIlAdi, HARITA_UST_KIRPMA, HARITA_GENISLIK, HARITA_YUKSEKLIK } from "../src/lib/harita";
import { ULKE_HARITALARI } from "../src/lib/harita-ulkeler-verisi";
const SUTUN = 72;
const HUCRE = HARITA_GENISLIK / SUTUN;
const YUKSEKLIK = HARITA_YUKSEKLIK - HARITA_UST_KIRPMA; // 447
const SATIR = Math.ceil(YUKSEKLIK / HUCRE);
type Nokta = [number, number];
type Hucre = [number, number];
function poligonlar(d: string): Nokta[][] {
const toks = d.match(/[MLHVCZmlhvcz]|-?\d*\.?\d+(?:e-?\d+)?/g) ?? [];
const polys: Nokta[][] = [];
let cur: Nokta[] = [];
let x = 0;
let y = 0;
let cmd = "";
let i = 0;
const num = () => Number(toks[i++]);
const kapat = () => {
if (cur.length > 2) polys.push(cur);
cur = [];
};
while (i < toks.length) {
const t = toks[i];
if (/[A-Za-z]/.test(t)) {
cmd = t;
i++;
if (cmd === "Z" || cmd === "z") kapat();
continue;
}
switch (cmd) {
case "M": x = num(); y = num(); kapat(); cur = [[x, y]]; cmd = "L"; break;
case "m": x += num(); y += num(); kapat(); cur = [[x, y]]; cmd = "l"; break;
case "L": x = num(); y = num(); cur.push([x, y]); break;
case "l": x += num(); y += num(); cur.push([x, y]); break;
case "H": x = num(); cur.push([x, y]); break;
case "h": x += num(); cur.push([x, y]); break;
case "V": y = num(); cur.push([x, y]); break;
case "v": y += num(); cur.push([x, y]); break;
case "C": num(); num(); num(); num(); x = num(); y = num(); cur.push([x, y]); break;
case "c": num(); num(); num(); num(); x += num(); y += num(); cur.push([x, y]); break;
default: i++;
}
}
kapat();
return polys;
}
function icinde(px: number, py: number, poly: Nokta[]): boolean {
let ic = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i];
const [xj, yj] = poly[j];
if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) ic = !ic;
}
return ic;
}
/** Her hücre merkezini ilk eşleşen bölgeye verir; sıra öncelik demektir. */
function rasterize(
bolgeler: { ad: string; polys: Nokta[][] }[],
ustKirpma: number,
): Map<string, Hucre[]> {
const sonuc = new Map<string, Hucre[]>();
for (let r = 0; r < SATIR; r++) {
for (let q = 0; q < SUTUN; q++) {
const px = (q + 0.5) * HUCRE;
const py = ustKirpma + (r + 0.5) * HUCRE;
const b = bolgeler.find((b) => b.polys.some((p) => icinde(px, py, p)));
if (!b) continue;
const liste = sonuc.get(b.ad) ?? [];
liste.push([q, r]);
sonuc.set(b.ad, liste);
}
}
return sonuc;
}
// ---- Türkiye illeri
const iller = cities.map((c) => ({ ad: normalizeIlAdi(c.name), polys: poligonlar(c.path) }));
const ilHucre = rasterize(iller, HARITA_UST_KIRPMA);
// Küçük il garantisi: 4 hücreden azı olan il bbox merkezinde 2×2 blok alır,
// o hücreler komşulardan düşer.
for (const il of iller) {
const mevcut = ilHucre.get(il.ad) ?? [];
if (mevcut.length >= 4) continue;
const pts = il.polys.flat();
const cx = (Math.min(...pts.map((p) => p[0])) + Math.max(...pts.map((p) => p[0]))) / 2;
const cy = (Math.min(...pts.map((p) => p[1])) + Math.max(...pts.map((p) => p[1]))) / 2;
const q0 = Math.min(SUTUN - 2, Math.max(0, Math.round(cx / HUCRE) - 1));
const r0 = Math.min(SATIR - 2, Math.max(0, Math.round((cy - HARITA_UST_KIRPMA) / HUCRE) - 1));
const blok: Hucre[] = [[q0, r0], [q0 + 1, r0], [q0, r0 + 1], [q0 + 1, r0 + 1]];
const anahtar = new Set(blok.map(([q, r]) => `${q},${r}`));
for (const [ad, liste] of ilHucre) {
if (ad === il.ad) continue;
ilHucre.set(ad, liste.filter(([q, r]) => !anahtar.has(`${q},${r}`)));
}
const birlesik = new Map(mevcut.map((h) => [`${h[0]},${h[1]}`, h]));
for (const h of blok) birlesik.set(`${h[0]},${h[1]}`, h);
ilHucre.set(il.ad, [...birlesik.values()]);
}
// ---- Ülkeler (KKTC, yurt dışı): ülke öncelikli, bağlam ikincil
const ulkeHucre: Record<string, { ulke: Hucre[]; baglam: Hucre[] }> = {};
for (const [kod, harita] of Object.entries(ULKE_HARITALARI)) {
const r = rasterize(
[
{ ad: "ulke", polys: harita.ulke.flatMap(poligonlar) },
{ ad: "baglam", polys: harita.baglam.flatMap(poligonlar) },
],
0,
);
ulkeHucre[kod] = { ulke: r.get("ulke") ?? [], baglam: r.get("baglam") ?? [] };
}
const enKucuk = [...ilHucre.entries()].sort((a, b) => a[1].length - b[1].length).slice(0, 5);
console.log(
`${SUTUN}×${SATIR}, hücre ${HUCRE.toFixed(3)}; kara ${[...ilHucre.values()].reduce((a, b) => a + b.length, 0)}; en küçük: ${enKucuk.map(([a, l]) => `${a}:${l.length}`).join(" ")}`,
);
const satirlar = [...ilHucre.entries()]
.sort((a, b) => a[0].localeCompare(b[0], "tr-TR"))
.map(([ad, liste]) => ` ${JSON.stringify(ad)}: ${JSON.stringify(liste)},`);
const ulkeSatirlar = Object.entries(ulkeHucre).map(
([kod, v]) => ` ${JSON.stringify(kod)}: { ulke: ${JSON.stringify(v.ulke)}, baglam: ${JSON.stringify(v.baglam)} },`,
);
const cikti = `// Bu dosya scripts/il-izgara-uret.ts tarafından üretildi — elle düzenleme.
// Hero'nun dot-matrix ızgarasında (${SUTUN} sütun × ${SATIR} satır, hücre ${HUCRE.toFixed(4)}
// viewBox birimi) her ilin ve ülkenin kapladığı hücreler [sütun, satır].
import type { UlkeKodu } from "./harita-ulkeler-verisi";
export const IL_IZGARA = {
sutun: ${SUTUN},
satir: ${SATIR},
hucre: ${HUCRE.toFixed(4)},
} as const;
export type IzgaraHucre = readonly [number, number];
/** Anahtar DB biçiminde il adı (normalizeIlAdi). */
export const IL_HUCRELERI: Record<string, readonly IzgaraHucre[]> = {
${satirlar.join("\n")}
};
export const ULKE_HUCRELERI: Record<UlkeKodu, { ulke: readonly IzgaraHucre[]; baglam: readonly IzgaraHucre[] }> = {
${ulkeSatirlar.join("\n")}
};
`;
const hedef = path.join(process.cwd(), "src/lib/il-izgara-verisi.ts");
fs.writeFileSync(hedef, cikti);
console.log("yazıldı:", hedef, `${(cikti.length / 1024).toFixed(0)} KB`);

View File

@@ -322,6 +322,31 @@
animation: kt-piksel-nabiz 0.42s cubic-bezier(0.23, 1, 0.32, 1); animation: kt-piksel-nabiz 0.42s cubic-bezier(0.23, 1, 0.32, 1);
} }
/* Şehir haritasında (sehir-piksel-haritasi.tsx) ilin kareleri sırayla
belirir; her karenin gecikmesi inline (35 ms × sıra). DESIGN.md §4.17. */
@keyframes kt-piksel-belir {
from {
opacity: 0;
transform: scale(0.6);
}
to {
opacity: 1;
transform: scale(1);
}
}
.kt-piksel-belir {
transform-box: fill-box;
transform-origin: center;
animation: kt-piksel-belir 0.42s cubic-bezier(0.23, 1, 0.32, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.kt-piksel-belir {
animation: none;
}
}
/* Bildirim (toast, sonner) — DESIGN.md §4.16. Başlık ve açıklama slate /* Bildirim (toast, sonner) — DESIGN.md §4.16. Başlık ve açıklama slate
tonlarında okunur; aksiyon düğmesi üst çubuktaki koyu hap dilinde. tonlarında okunur; aksiyon düğmesi üst çubuktaki koyu hap dilinde.
".toaster" öneki şart: sonner kendi stilini çalışma anında enjekte eder, ".toaster" öneki şart: sonner kendi stilini çalışma anında enjekte eder,

View File

@@ -20,6 +20,8 @@ import {
import { BASLIK_YILI, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari"; import { BASLIK_YILI, SON_YIL, TERCIH_YILI } from "@/lib/veri-yillari";
import { NavBaslik } from "./nav-baslik"; import { NavBaslik } from "./nav-baslik";
import { SehirCipleri, oneCikanIller } from "./sehir-cipleri"; import { SehirCipleri, oneCikanIller } from "./sehir-cipleri";
import { SehirPikselHaritasi } from "./sehir-piksel-haritasi";
import { SehirYuzeyi } from "./sehir-yuzeyi";
import { UniLinkleri } from "./universite-listesi"; import { UniLinkleri } from "./universite-listesi";
const sayi = (n: number) => n.toLocaleString("tr-TR"); const sayi = (n: number) => n.toLocaleString("tr-TR");
@@ -123,9 +125,16 @@ export function IlIcerik({ slug }: { slug: string }) {
</nav> </nav>
<NavBaslik baslikId="il-baslik" ad={`${il.ad} üniversiteleri`} /> <NavBaslik baslikId="il-baslik" ad={`${il.ad} üniversiteleri`} />
{/* 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 <h1
id="il-baslik" 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" className="font-heading text-3xl font-bold leading-tight text-slate-900 sm:text-5xl"
> >
{il.ad} Üniversiteleri Taban Puanları {BASLIK_YILI} {il.ad} Üniversiteleri Taban Puanları {BASLIK_YILI}
</h1> </h1>
@@ -146,16 +155,19 @@ export function IlIcerik({ slug }: { slug: string }) {
</span> </span>
</p> </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"> <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{" "} {il.ad}&apos;da {SON_YIL} YKS yerleştirmesinde öğrenci alan{" "}
<strong>{sayi(il.uniler.length)} üniversite</strong> <strong>{sayi(il.uniler.length)} üniversite</strong>
{ozetParcalar.length > 1 ? ` (${ozetParcalar.join(", ")})` : ""} ve {ozetParcalar.length > 1 ? ` (${ozetParcalar.join(", ")})` : ""}{" "}
toplam <strong>{sayi(il.programSayisi)} program</strong> var. ve toplam <strong>{sayi(il.programSayisi)} program</strong> var.
Üniversiteye tıklayınca programlarının taban puanlarını, başarı Ü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 sıralamalarını ve kontenjanlarını fakülte fakülte görürsün.
YÖK Atlas {SON_YIL} yerleştirme sonuçlarına dayanır; {TERCIH_YILI}{" "} Veriler YÖK Atlas {SON_YIL} yerleştirme sonuçlarına dayanır;{" "}
tercihleri için rehber niteliğindedir. {TERCIH_YILI} tercihleri için rehber niteliğindedir.
</p> </p>
</div>
}
/>
<div className="mt-8"> <div className="mt-8">
<CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} /> <CtaSiraForm varyant="kompakt" konum="ust" baslik={ctaBaslik} />
@@ -196,12 +208,7 @@ export function IlIcerikSkeleton() {
return ( return (
<main className="mx-auto w-full max-w-5xl px-4 py-12 sm:py-16"> <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 h-4 w-64 lg:mx-0" />
<Skeleton className="mx-auto mt-6 h-10 w-full max-w-xl lg:mx-0" /> <Skeleton className="mt-6 h-72 w-full rounded-3xl" />
<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" />
<div className="mt-10 space-y-4"> <div className="mt-10 space-y-4">
<Skeleton className="h-8 w-72" /> <Skeleton className="h-8 w-72" />
{Array.from({ length: 6 }, (_, i) => ( {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 { cities } from "turkey-map-react/lib/data";
import { import {
HARITA_GENISLIK, TurkiyePikselHaritasi,
HARITA_UST_KIRPMA, UlkePikselHaritasi,
HARITA_YUKSEKLIK, } from "./sehir-piksel-haritasi";
normalizeIlAdi,
} from "@/lib/harita";
import {
ULKE_HARITA_GENISLIK,
ULKE_HARITA_YUKSEKLIK,
uniUlkeKonum,
type UlkeKonum,
} from "@/lib/harita-ulkeler";
const SECILI_DOLGU = "oklch(0.809 0.105 251.8)"; // Harita il adları modül yükünde bir kez DB biçimine çevrilir.
const ZEMIN_DOLGU = "oklch(0.929 0.013 255.5)"; 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({ export function UniversiteKonumHaritasi({
universite, universite,
il, il,
@@ -24,9 +24,15 @@ export function UniversiteKonumHaritasi({
}) { }) {
const yurtdisi = uniUlkeKonum(universite, il); const yurtdisi = uniUlkeKonum(universite, il);
if (yurtdisi) { if (yurtdisi) {
const konum = yurtdisiUniKonum(universite, il);
return ( return (
<KonumBolumu universite={universite}> <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"> <p className="pointer-events-none absolute bottom-0 left-0 text-xs font-medium text-slate-500">
{yurtdisi.ulkeAd} {yurtdisi.ulkeAd}
{yurtdisi.sehir ? ` · ${yurtdisi.sehir}` : ""} {yurtdisi.sehir ? ` · ${yurtdisi.sehir}` : ""}
@@ -35,31 +41,15 @@ export function UniversiteKonumHaritasi({
); );
} }
if (!il) return null; if (!il || !HARITA_ILLERI.has(il)) return null;
return ( return (
<KonumBolumu universite={universite}> <KonumBolumu universite={universite}>
<svg <TurkiyePikselHaritasi
viewBox={`0 ${HARITA_UST_KIRPMA} ${HARITA_GENISLIK} ${HARITA_YUKSEKLIK - HARITA_UST_KIRPMA}`} il={il}
etiket={`${universite} konumunun Türkiye haritasındaki görünümü`}
className="absolute inset-0 h-full w-full" 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> </KonumBolumu>
); );
} }
@@ -83,30 +73,3 @@ function KonumBolumu({
</section> </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>
);
}

108
src/lib/il-izgara-verisi.ts Normal file

File diff suppressed because one or more lines are too long