fix(tasarim): şehir haritası kartı arka plan gibi kaplar, kenarlar erir; pikseller azaldı
All checks were successful
Deploy / deploy (push) Successful in 4m34s

- Izgara 72 → 56 sütun, serpinti kaldırıldı (Bilal: "piksel detaylarını azalt").
- Harita `doldur` (slice) ile SehirYuzeyi kartını kaplar, metin üstünde; iki
  katlı mask ile hiçbir kenarda kesik kare kalmaz (Bilal: "canvas net bir
  şekilde kesiliyor"). Telefonda altta, lg'de sağ yarıda belirir.
- Yakınlaşma kutusu kabın oranında kurulur, il `odakHiza` ile kabın istenen
  noktasına gelir (şehir sayfası: lg sağ orta, telefon alt orta; konum kartı orta).
- usePixelHeat meet/slice + ortalama ofsetini hesaba katar.
- Üniversite konum kartı: kartı doldurur, radyal erime.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-24 15:10:44 +03:00
parent 6e057aa0f5
commit c60e3ab409
8 changed files with 232 additions and 203 deletions

View File

@@ -459,10 +459,10 @@ bulunulan şehir `border-primary bg-primary text-white` + `aria-current="page"`.
- **Şehir piksel haritası** — `sehir-piksel-haritasi.tsx`: **hero'daki dot-matrix Türkiye'nin - **Ş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 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" 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` → seçildi). Izgara 56 sütun × 24 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ı `src/lib/il-izgara-verisi.ts`; her il en az 2×2 kare; Bilal: "piksel detaylarını azalt"),
opaklık, kıyının altına seyrelen serpinti, `text-primary` zemin, imleç yaklaşınca turuncuya kare hücrenin %74'ü, altı farklı opaklık, serpinti yok, `text-primary` zemin, imleç
ısınma (`usePixelHeat`). Bulunulan ilin kareleri `fill-orange-500`, hücrenin %88'i kadar yaklaşınca turuncuya ısınma (`usePixelHeat`, meet/slice ve canlı viewBox ile). 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` 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, 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 şehir merkezinde 2×2 hücrelik tek iri turuncu kare; şehri bilinmeyen "KIBRIS" için ülkenin
@@ -470,12 +470,19 @@ bulunulan şehir `border-primary bg-primary text-white` + `aria-current="page"`.
`role="img"` + "İstanbul Türkiye haritasında". Üniversite sayfasının konum kartı `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), (`universite-konum-haritasi.tsx`) aynı bileşeni kullanır. Veri taşıyan pikseldir (§1.2),
süs ayraç yasağına girmez. süs ayraç yasağına girmez.
- **Harita arka plandır, kesilmez** (Bilal, 24 Eyl 2026: "canvas net bir şekilde kesiliyor,
çok kötü"; "harita bulunduğu yerin tamamını kaplasın"). Şehir sayfasında `SehirYuzeyi`
kartını `doldur` (slice) ile kaplar, metin üstünde; kenarlar `mask-image` ile erir —
telefonda yukarıdan (metnin altında belirir), lg'de soldan (sağ yarıda belirir). Metin
sütunu lg'de %58, kart en az 26rem; telefonda altta 12–14rem harita payı. Üniversite konum
kartında radyal mask. Sert kenarlı, kırpılmış bir SVG asla görünmez.
- **Yakınlaşma** (Bilal, 24 Eyl 2026: "ilin bulunduğu yere doğru harita büyüsün, sayfa - **Yakınlaşma** (Bilal, 24 Eyl 2026: "ilin bulunduğu yere doğru harita büyüsün, sayfa
yüklendikten sonra delay ile motion ile"): SSR tüm ülke; yüklenince 0,7 s sonra viewBox 1,1 s yüklendikten sonra delay ile motion ile"): SSR tüm ülke; yüklenince 0,7 s sonra viewBox 1,1 s
boyunca güçlü ease-out (`0.23, 1, 0.32, 1`) ile ilin çevresindeki kutuya kayar — en çok 2,4 boyunca güçlü ease-out (`0.23, 1, 0.32, 1`) ile ilin çevresindeki kutuya kayar — en çok 2,4
kat (ülke haritasında şehir işaretine 1,8 kat), kutu SVG ile aynı oranda ve harita dışına kat (ülke haritasında şehir işaretine 1,8 kat). Kutu kabın o anki oranında kurulur ve il
taşmaz. Kareler büyür, il kadraja gelir; ısı fırçası viewBox'ı canlı okur. Hareketi azalt `odakHiza` ile kabın istenen noktasına gelir (şehir sayfası: lg'de x 0,74 / y 0,5, telefonda
açıkken kutuya doğrudan atlanır. Sayfa başına tek yakınlaşma; imleçle geri alınmaz. x 0,5 / y 0,78; konum kartı: orta). Kareler büyür, il kadraja gelir; ısı fırçası viewBox'ı
canlı okur. Hareketi azalt açıkken kutuya doğrudan atlanır. Sayfa başına tek yakınlaşma.
## 5. Yerleşim ## 5. Yerleşim

View File

@@ -1,6 +1,6 @@
// Şehir haritası (DESIGN.md §4.17) için il ve ülke kontürlerini hero'nun // Ş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; // 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 // burada 56 sütun (Bilal, 24 Eyl 2026: "piksel detaylarını azalt"); her il en az birkaç kare alsın (Yalova, Kilis gibi
// küçük iller için 2×2 blok garantisi vardır). Çıktı // 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. // src/lib/il-izgara-verisi.ts dosyasına yazılır ve commit'lenir.
// //
@@ -17,7 +17,7 @@ import { cities } from "turkey-map-react/lib/data";
import { normalizeIlAdi, HARITA_UST_KIRPMA, HARITA_GENISLIK, HARITA_YUKSEKLIK } from "../src/lib/harita"; import { normalizeIlAdi, HARITA_UST_KIRPMA, HARITA_GENISLIK, HARITA_YUKSEKLIK } from "../src/lib/harita";
import { ULKE_HARITALARI } from "../src/lib/harita-ulkeler-verisi"; import { ULKE_HARITALARI } from "../src/lib/harita-ulkeler-verisi";
const SUTUN = 72; const SUTUN = 56;
const HUCRE = HARITA_GENISLIK / SUTUN; const HUCRE = HARITA_GENISLIK / SUTUN;
const YUKSEKLIK = HARITA_YUKSEKLIK - HARITA_UST_KIRPMA; // 447 const YUKSEKLIK = HARITA_YUKSEKLIK - HARITA_UST_KIRPMA; // 447
const SATIR = Math.ceil(YUKSEKLIK / HUCRE); const SATIR = Math.ceil(YUKSEKLIK / HUCRE);

View File

@@ -37,6 +37,8 @@ type HeatTarget = {
type HeatInstance = { type HeatInstance = {
svg: SVGSVGElement; svg: SVGSVGElement;
radius: number; radius: number;
/** preserveAspectRatio "slice" mi (kabı doldurur) */
slice: boolean;
baseColor: string; baseColor: string;
pixels: HeatTarget[]; pixels: HeatTarget[];
hot: Set<SVGRectElement>; hot: Set<SVGRectElement>;
@@ -68,8 +70,14 @@ function tara() {
// yüklendikten sonra ile doğru yakınlaşır; kutunun oranı SVG'ninkiyle // yüklendikten sonra ile doğru yakınlaşır; kutunun oranı SVG'ninkiyle
// aynı tutulduğu için ölçek = genişlik oranı, orijin = viewBox.x/y. // aynı tutulduğu için ölçek = genişlik oranı, orijin = viewBox.x/y.
const vb = inst.svg.viewBox.baseVal; const vb = inst.svg.viewBox.baseVal;
if (!vb.width) continue; if (!vb.width || !vb.height) continue;
const scale = rect.width / vb.width; // xMidYMid meet (varsayılan) ya da slice: ölçek en küçük/en büyük oran,
// artan kenar ortalanır.
const sx = rect.width / vb.width;
const sy = rect.height / vb.height;
const scale = inst.slice ? Math.max(sx, sy) : Math.min(sx, sy);
const ox = (rect.width - vb.width * scale) / 2;
const oy = (rect.height - vb.height * scale) / 2;
const pad = inst.radius * scale; const pad = inst.radius * scale;
// İmleç fırça menzilinin tamamen dışındaysa ve sıcak piksel yoksa // İmleç fırça menzilinin tamamen dışındaysa ve sıcak piksel yoksa
// bu örnek için hiç hesap yapma // bu örnek için hiç hesap yapma
@@ -82,8 +90,8 @@ function tara() {
) { ) {
continue; continue;
} }
const x = vb.x + (e.clientX - rect.left) / scale; const x = vb.x + (e.clientX - rect.left - ox) / scale;
const y = vb.y + (e.clientY - rect.top) / scale; const y = vb.y + (e.clientY - rect.top - oy) / scale;
for (const p of inst.pixels) { for (const p of inst.pixels) {
const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2; const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2;
const isHot = d2 <= inst.radius * inst.radius; const isHot = d2 <= inst.radius * inst.radius;
@@ -159,6 +167,7 @@ export function usePixelHeat(
const inst: HeatInstance = { const inst: HeatInstance = {
svg, svg,
radius, radius,
slice: (svg.getAttribute("preserveAspectRatio") ?? "").includes("slice"),
baseColor: getComputedStyle(svg).color, baseColor: getComputedStyle(svg).color,
pixels, pixels,
hot: new Set(), hot: new Set(),

View File

@@ -125,11 +125,17 @@ 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 {/* Başlık yüzeyi: beyaz kart, arka planı kaplayan dot-matrix şehir
haritası (DESIGN.md §4.17). lg altında metin ortalı, harita altta. */} haritası (DESIGN.md §4.17). lg altında metin ortalı, harita altta. */}
<SehirYuzeyi <SehirYuzeyi
seed={31} harita={
harita={<SehirPikselHaritasi il={il.il} ad={il.ad} />} <SehirPikselHaritasi
il={il.il}
ad={il.ad}
doldur
odakHiza={{ lg: { x: 0.74, y: 0.5 }, dar: { x: 0.5, y: 0.78 } }}
/>
}
metin={ metin={
<div className="text-center lg:text-left"> <div className="text-center lg:text-left">
<h1 <h1

View File

@@ -5,9 +5,10 @@
* Türkiye'nin (turkey-pixel-map.tsx) şehir sayfası ve üniversite konum kartı * 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 * 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 * 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 * (usePixelHeat). Fark: ızgara 56 sütun (her il en az 2×2 kare; Bilal, 24
* bulunulan ilin kareleri turuncu, hücrenin %88'i kadar iri (Bilal, 24 Eyl * Eyl 2026: "piksel detaylarını azalt"), serpinti yok, bulunulan ilin
* 2026: "C'deki gibi piksel olabilir"), sırayla belirir ve nabız atar. * 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 * Markup hero'daki gibi deterministik string olarak üretilir ve
* dangerouslySetInnerHTML ile basılır: ~1300 rect React elemanı olarak * dangerouslySetInnerHTML ile basılır: ~1300 rect React elemanı olarak
@@ -16,8 +17,12 @@
* Yakınlaşma (Bilal, 24 Eyl 2026: "ilin bulunduğu yere doğru harita büyüsün, * 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 * 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_GECIKME sonra viewBox, ilin çevresindeki kutuya doğru (en çok
* ODAK_ZOOM kat, SVG ile aynı oranda) güçlü ease-out ile kayar — kareler * ODAK_ZOOM kat) güçlü ease-out ile kayar — kareler büyür, il kadraja
* büyür, il kadraja gelir. Hareketi azalt açıkken kutuya doğrudan atlanır. * 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 { useEffect, useRef } from "react";
@@ -38,28 +43,28 @@ const INSET = (CELL - DOT) / 2;
// Vurgu kareleri iri ve dolu (C seçeneğinin hissi) // Vurgu kareleri iri ve dolu (C seçeneğinin hissi)
const DOT_VURGU = CELL * 0.88; const DOT_VURGU = CELL * 0.88;
const INSET_VURGU = (CELL - DOT_VURGU) / 2; 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_W = SUTUN * CELL;
const VIEW_H = (SATIR + SERPINTI) * CELL; const VIEW_H = SATIR * CELL;
// Hero'nun ısı fırçası 640 birimlik viewBox'ta 34; burada aynı oran. // Hero'nun ısı fırçası 640 birimlik viewBox'ta 34; burada aynı oran.
const ISI_YARICAPI = 34 * (VIEW_W / 640); 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) // Yakınlaşma: gecikme (s), süre (s), en çok büyütme, ilin çevresinde pay (hücre)
const ODAK_GECIKME = 0.7; const ODAK_GECIKME = 0.7;
const ODAK_SURE = 1.1; const ODAK_SURE = 1.1;
const ODAK_ZOOM = 2.4; const ODAK_ZOOM = 2.4;
const ODAK_PAY = 3; const ODAK_PAY = 2;
const ODAK_EASE = [0.23, 1, 0.32, 1] as const; const ODAK_EASE = [0.23, 1, 0.32, 1] as const;
type Kutu = readonly [number, number, number, number]; type Kutu = readonly [number, number, number, number];
const TAM_KUTU: Kutu = [0, 0, VIEW_W, VIEW_H]; 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). */
* Vurgu hücrelerinin çevresine, SVG ile aynı oranda ve en çok ODAK_ZOOM kat export type OdakHiza = { readonly x: number; readonly y: number };
* büyüten odak kutusu; harita dışına taşmayacak şekilde kaydırılır. const ORTA: OdakHiza = { x: 0.5, y: 0.5 };
*/
function odakKutusu(hucreler: readonly IzgaraHucre[], zoom = ODAK_ZOOM): Kutu { function sinirKutusu(hucreler: readonly IzgaraHucre[]): Sinir | null {
if (hucreler.length === 0) return TAM_KUTU; if (hucreler.length === 0) return null;
let q0 = Infinity, q1 = -Infinity, r0 = Infinity, r1 = -Infinity; let q0 = Infinity, q1 = -Infinity, r0 = Infinity, r1 = -Infinity;
for (const [q, r] of hucreler) { for (const [q, r] of hucreler) {
if (q < q0) q0 = q; if (q < q0) q0 = q;
@@ -67,9 +72,21 @@ function odakKutusu(hucreler: readonly IzgaraHucre[], zoom = ODAK_ZOOM): Kutu {
if (r < r0) r0 = r; if (r < r0) r0 = r;
if (r > r1) r1 = r; if (r > r1) r1 = r;
} }
const x0 = (q0 - ODAK_PAY) * CELL, x1 = (q1 + 1 + ODAK_PAY) * CELL; return [
const y0 = (r0 - ODAK_PAY) * CELL, y1 = (r1 + 1 + ODAK_PAY) * CELL; (q0 - ODAK_PAY) * CELL,
const oran = VIEW_W / VIEW_H; (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 w = Math.max(x1 - x0, VIEW_W / zoom);
let h = w / oran; let h = w / oran;
if (h < y1 - y0) { if (h < y1 - y0) {
@@ -77,9 +94,7 @@ function odakKutusu(hucreler: readonly IzgaraHucre[], zoom = ODAK_ZOOM): Kutu {
w = h * oran; w = h * oran;
} }
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2; const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
const x = Math.min(Math.max(cx - w / 2, 0), VIEW_W - w); return [cx - w * hiza.x, cy - h * hiza.y, w, h];
const y = Math.min(Math.max(cy - h / 2, 0), VIEW_H - h);
return [x, y, w, h];
} }
const OPACITIES = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19]; const OPACITIES = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
@@ -89,12 +104,6 @@ const VURGU_OPACITIES = [1, 0.9, 0.82, 0.95];
const vurguOpacityFor = (x: number, y: number) => const vurguOpacityFor = (x: number, y: number) =>
VURGU_OPACITIES[(x * 3 + y * 5) % VURGU_OPACITIES.length]; 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); const f = (n: number) => n.toFixed(2);
function zeminKare(q: number, r: number, op: number) { function zeminKare(q: number, r: number, op: number) {
@@ -116,25 +125,9 @@ function markupUret(
): Harita { ): Harita {
const parts: string[] = []; const parts: string[] = [];
const vurguSet = new Set(vurgu.map(([q, r]) => `${q},${r}`)); const vurguSet = new Set(vurgu.map(([q, r]) => `${q},${r}`));
const enAlt: number[] = Array(SUTUN).fill(-1);
for (const [q, r] of zemin) { for (const [q, r] of zemin) {
if (vurguSet.has(`${q},${r}`)) continue; if (vurguSet.has(`${q},${r}`)) continue;
parts.push(zeminKare(q, r, opacityFor(q, r))); 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 // Vurgu: sırayla belirir (kt-piksel-belir), grup hâlinde nabız atar
parts.push( parts.push(
@@ -151,12 +144,12 @@ function markupUret(
parts.push("</g>"); parts.push("</g>");
// Odak: il kareleri; ülke haritasında şehir işareti (2×2) ya da ülkenin kendisi // Odak: il kareleri; ülke haritasında şehir işareti (2×2) ya da ülkenin kendisi
const odak = isaret const odak = isaret
? odakKutusu([isaret, [isaret[0] + 1, isaret[1] + 1]], 1.8) ? { sinir: sinirKutusu([isaret, [isaret[0] + 1, isaret[1] + 1]]), zoom: 1.8 }
: odakKutusu(vurgu); : { sinir: sinirKutusu(vurgu), zoom: ODAK_ZOOM };
return { html: parts.join(""), odak }; return { html: parts.join(""), ...odak };
} }
type Harita = { html: string; odak: Kutu }; type Harita = { html: string; sinir: Sinir | null; zoom: number };
const onbellek = new Map<string, Harita>(); const onbellek = new Map<string, Harita>();
function markup(anahtar: string, uret: () => Harita): Harita { function markup(anahtar: string, uret: () => Harita): Harita {
@@ -170,24 +163,37 @@ function markup(anahtar: string, uret: () => Harita): Harita {
const kutuMetni = (k: Kutu) => k.map((n) => n.toFixed(1)).join(" "); 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({ function PikselSvg({
harita, harita,
etiket, etiket,
className, className,
}: { doldur = false,
harita: Harita; odakHiza,
etiket: string; }: HaritaProps & { harita: Harita }) {
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef, { radius: ISI_YARICAPI }); usePixelHeat(svgRef, { radius: ISI_YARICAPI });
// Yüklenince gecikmeyle odağa yakınlaş. viewBox her karede yazılır; ısı // 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. // fırçası viewBox'ı canlı okuduğu için imleç hep doğru kareyi ısıtır.
const { odak } = harita; const { sinir, zoom } = harita;
const hizaLg = odakHiza?.lg ?? ORTA;
const hizaDar = odakHiza?.dar ?? ORTA;
useEffect(() => { useEffect(() => {
const svg = svgRef.current; const svg = svgRef.current;
if (!svg || odak === TAM_KUTU) return; 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)); const yaz = (k: Kutu) => svg.setAttribute("viewBox", kutuMetni(k));
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
yaz(odak); yaz(odak);
@@ -210,13 +216,14 @@ function PikselSvg({
kontrol.stop(); kontrol.stop();
yaz(TAM_KUTU); yaz(TAM_KUTU);
}; };
}, [odak]); }, [sinir, zoom, hizaLg, hizaDar]);
return ( return (
<svg <svg
ref={svgRef} ref={svgRef}
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`} viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className={cn("h-auto w-full text-primary", className)} preserveAspectRatio={doldur ? "xMidYMid slice" : "xMidYMid meet"}
className={cn(doldur ? "h-full w-full" : "h-auto w-full", "text-primary", className)}
role="img" role="img"
aria-label={etiket} aria-label={etiket}
// Statik, deterministik markup — her render'da yeniden kurulmaz // Statik, deterministik markup — her render'da yeniden kurulmaz
@@ -230,17 +237,12 @@ const TUM_IL_HUCRELERI = Object.values(IL_HUCRELERI).flat();
/** Türkiye; `il` (DB biçimi, ör. "İZMİR") turuncu karelerle yanar. */ /** Türkiye; `il` (DB biçimi, ör. "İZMİR") turuncu karelerle yanar. */
export function TurkiyePikselHaritasi({ export function TurkiyePikselHaritasi({
il, il,
etiket, ...props
className, }: HaritaProps & { il: string }) {
}: {
il: string;
etiket: string;
className?: string;
}) {
const harita = markup(`tr:${il}`, () => const harita = markup(`tr:${il}`, () =>
markupUret(TUM_IL_HUCRELERI, IL_HUCRELERI[il] ?? [], null), markupUret(TUM_IL_HUCRELERI, IL_HUCRELERI[il] ?? [], null),
); );
return <PikselSvg harita={harita} etiket={etiket} className={className} />; return <PikselSvg harita={harita} {...props} />;
} }
/** /**
@@ -250,14 +252,11 @@ export function TurkiyePikselHaritasi({
export function UlkePikselHaritasi({ export function UlkePikselHaritasi({
ulke, ulke,
sehir, sehir,
etiket, ...props
className, }: HaritaProps & {
}: {
ulke: UlkeKodu; ulke: UlkeKodu;
/** viewBox koordinatı (harita-ulkeler'deki proje ile) */ /** viewBox koordinatı (harita-ulkeler'deki proje ile) */
sehir: { x: number; y: number } | null; sehir: { x: number; y: number } | null;
etiket: string;
className?: string;
}) { }) {
const harita = markup( const harita = markup(
`ulke:${ulke}:${sehir ? `${Math.round(sehir.x)},${Math.round(sehir.y)}` : "-"}`, `ulke:${ulke}:${sehir ? `${Math.round(sehir.x)},${Math.round(sehir.y)}` : "-"}`,
@@ -271,27 +270,26 @@ export function UlkePikselHaritasi({
return markupUret(h.baglam, h.ulke, null); return markupUret(h.baglam, h.ulke, null);
}, },
); );
return <PikselSvg harita={harita} etiket={etiket} className={className} />; return <PikselSvg harita={harita} {...props} />;
} }
/** Şehir sayfası: il değerinden Türkiye ya da ülke haritasını seçer. */ /** Şehir sayfası: il değerinden Türkiye ya da ülke haritasını seçer. */
export function SehirPikselHaritasi({ export function SehirPikselHaritasi({
il, il,
ad, ad,
className, ...props
}: { }: Omit<HaritaProps, "etiket"> & {
/** DB biçiminde il ("İSTANBUL", "LEFKOŞA", "KIBRIS") */ /** DB biçiminde il ("İSTANBUL", "LEFKOŞA", "KIBRIS") */
il: string; il: string;
/** Görünen ad (etiket için) */ /** Görünen ad (etiket için) */
ad: string; ad: string;
className?: string;
}) { }) {
if (IL_HUCRELERI[il]) { if (IL_HUCRELERI[il]) {
return ( return (
<TurkiyePikselHaritasi <TurkiyePikselHaritasi
il={il} il={il}
etiket={`${ad} Türkiye haritasında`} etiket={`${ad} Türkiye haritasında`}
className={className} {...props}
/> />
); );
} }
@@ -302,7 +300,7 @@ export function SehirPikselHaritasi({
ulke={ulke.ulke} ulke={ulke.ulke}
sehir={ulke.sehir ? { x: ulke.sehir.x, y: ulke.sehir.y } : null} sehir={ulke.sehir ? { x: ulke.sehir.x, y: ulke.sehir.y } : null}
etiket={`${ad} ${ulke.ulkeAd} haritasında`} etiket={`${ad} ${ulke.ulkeAd} haritasında`}
className={className} {...props}
/> />
); );
} }

View File

@@ -1,34 +1,38 @@
"use client"; import type { ReactNode } from "react";
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 * Şehir sayfasının başlık yüzeyi (DESIGN.md §4.17): beyaz kart, arka
* piksel doku, sağda (lg) ya da altta (telefon) şehrin dot-matrix haritası. * planında kartı dolduran dot-matrix şehir haritası (hero'daki gibi metnin
* Doku ile harita üst üste binmez: harita kabı `haric` ile zemine verilir * arkasında). Harita `doldur` ile kabı kaplar; kenarları mask ile erir ki
* (§10.8 kuralı). Yalnız ref için client; metin ve harita sunucudan gelir. * kesik bir kanvas görünmesin (Bilal, 24 Eyl 2026: "canvas net bir şekilde
* kesiliyor, çok kötü"). Geniş ekranda (lg) metin solda, harita sağ yarıda
* belirginleşir; telefonda metin üstte, harita altta.
* Ayrı piksel doku (PikselZemin) yok: harita zaten piksel.
*/ */
export function SehirYuzeyi({ export function SehirYuzeyi({
metin, metin,
harita, harita,
seed = 31,
}: { }: {
metin: ReactNode; metin: ReactNode;
harita: ReactNode; harita: ReactNode;
seed?: number;
}) { }) {
const haritaRef = useRef<HTMLDivElement>(null);
return ( return (
<section className="relative isolate mt-6 overflow-hidden rounded-3xl border border-slate-200 bg-white p-6 sm:p-8"> <section className="relative isolate mt-6 overflow-hidden rounded-3xl border border-slate-200 bg-white">
<PikselZemin ton="acik" seed={seed} haric={haritaRef} /> {/* İki katlı mask: dış kat metnin tarafına doğru eritir (telefonda
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,24rem)] lg:items-center lg:gap-10"> yukarı, lg'de sola), iç kat dik yöndeki iki kenarı eritir — hiçbir
<div>{metin}</div> kenarda yarım kesilmiş kare kalmaz. */}
{harita ? ( {harita ? (
<div ref={haritaRef} className="mx-auto w-full max-w-md lg:max-w-none"> <div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 [mask-image:linear-gradient(to_bottom,transparent_0%,rgba(0,0,0,0.18)_40%,black_74%,black_90%,transparent_100%)] lg:[mask-image:linear-gradient(to_right,transparent_28%,rgba(0,0,0,0.22)_50%,black_72%,black_92%,transparent_100%)]"
>
<div className="h-full w-full [mask-image:linear-gradient(to_right,transparent_0%,black_10%,black_90%,transparent_100%)] lg:[mask-image:linear-gradient(to_bottom,transparent_0%,black_14%,black_86%,transparent_100%)]">
{harita} {harita}
</div> </div>
) : null} </div>
) : null}
<div className="p-6 pb-48 sm:p-8 sm:pb-56 lg:min-h-[26rem] lg:max-w-[58%] lg:pb-8">
{metin}
</div> </div>
</section> </section>
); );

View File

@@ -8,6 +8,9 @@ import {
// Harita il adları modül yükünde bir kez DB biçimine çevrilir. // 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))); const HARITA_ILLERI = new Set(cities.map((c) => normalizeIlAdi(c.name)));
// Kartı doldurur, kenarları radyal mask ile erir (kesik kanvas yok).
const KART_HARITA_SINIFI =
"absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,black_55%,transparent_100%)]";
/** /**
* Üniversite sayfasındaki konum kartı: şehir sayfasıyla aynı dot-matrix * Üniversite sayfasındaki konum kartı: şehir sayfasıyla aynı dot-matrix
@@ -31,7 +34,8 @@ export function UniversiteKonumHaritasi({
ulke={yurtdisi.ulke} ulke={yurtdisi.ulke}
sehir={konum ? { x: konum.x, y: konum.y } : null} sehir={konum ? { x: konum.x, y: konum.y } : null}
etiket={`${universite} konumunun ${yurtdisi.ulkeAd} haritasındaki görünümü${yurtdisi.sehir ? ` (${yurtdisi.sehir})` : ""}`} etiket={`${universite} konumunun ${yurtdisi.ulkeAd} haritasındaki görünümü${yurtdisi.sehir ? ` (${yurtdisi.sehir})` : ""}`}
className="absolute inset-0 h-full w-full" doldur
className={KART_HARITA_SINIFI}
/> />
<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}
@@ -48,7 +52,8 @@ export function UniversiteKonumHaritasi({
<TurkiyePikselHaritasi <TurkiyePikselHaritasi
il={il} il={il}
etiket={`${universite} konumunun Türkiye haritasındaki görünümü`} etiket={`${universite} konumunun Türkiye haritasındaki görünümü`}
className="absolute inset-0 h-full w-full" doldur
className={KART_HARITA_SINIFI}
/> />
</KonumBolumu> </KonumBolumu>
); );

File diff suppressed because one or more lines are too long