diff --git a/docs/tasarim/DESIGN.md b/docs/tasarim/DESIGN.md index 6ec313d..ae298e7 100644 --- a/docs/tasarim/DESIGN.md +++ b/docs/tasarim/DESIGN.md @@ -470,6 +470,12 @@ bulunulan şehir `border-primary bg-primary text-white` + `aria-current="page"`. `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. +- **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 + 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 + taşmaz. 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; imleçle geri alınmaz. ## 5. Yerleşim diff --git a/src/components/use-pixel-heat.ts b/src/components/use-pixel-heat.ts index faf9e85..33a3108 100644 --- a/src/components/use-pixel-heat.ts +++ b/src/components/use-pixel-heat.ts @@ -36,7 +36,6 @@ type HeatTarget = { type HeatInstance = { svg: SVGSVGElement; - viewW: number; radius: number; baseColor: string; pixels: HeatTarget[]; @@ -65,7 +64,12 @@ function tara() { for (const inst of instances) { const rect = inst.svg.getBoundingClientRect(); if (rect.width === 0) continue; - const scale = rect.width / inst.viewW; + // viewBox her taramada okunur: şehir haritası (sehir-piksel-haritasi.tsx) + // 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. + const vb = inst.svg.viewBox.baseVal; + if (!vb.width) continue; + const scale = rect.width / vb.width; const pad = inst.radius * scale; // İmleç fırça menzilinin tamamen dışındaysa ve sıcak piksel yoksa // bu örnek için hiç hesap yapma @@ -78,8 +82,8 @@ function tara() { ) { continue; } - const x = (e.clientX - rect.left) / scale; - const y = (e.clientY - rect.top) / scale; + const x = vb.x + (e.clientX - rect.left) / scale; + const y = vb.y + (e.clientY - rect.top) / scale; for (const p of inst.pixels) { const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2; const isHot = d2 <= inst.radius * inst.radius; @@ -140,8 +144,7 @@ export function usePixelHeat( // Dokunmatik cihazlarda hover yanlış tetiklenir; efekti imleçli cihazlara sakla if (!window.matchMedia(FINE_POINTER_QUERY).matches) return; - const viewW = svg.viewBox.baseVal.width; - if (!viewW) return; + if (!svg.viewBox.baseVal.width) return; const pixels: HeatTarget[] = Array.from( svg.querySelectorAll("[data-px]"), @@ -155,7 +158,6 @@ export function usePixelHeat( const inst: HeatInstance = { svg, - viewW, radius, baseColor: getComputedStyle(svg).color, pixels, diff --git a/src/features/katalog/components/sehir-piksel-haritasi.tsx b/src/features/katalog/components/sehir-piksel-haritasi.tsx index 608a52f..8e9b1cc 100644 --- a/src/features/katalog/components/sehir-piksel-haritasi.tsx +++ b/src/features/katalog/components/sehir-piksel-haritasi.tsx @@ -12,9 +12,16 @@ * 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, SVG ile aynı oranda) güçlü ease-out ile kayar — kareler + * büyür, il kadraja gelir. Hareketi azalt açıkken kutuya doğrudan atlanır. */ -import { useRef } from "react"; +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 { @@ -37,6 +44,43 @@ 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); +// 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_SURE = 1.1; +const ODAK_ZOOM = 2.4; +const ODAK_PAY = 3; +const ODAK_EASE = [0.23, 1, 0.32, 1] as const; + +type Kutu = readonly [number, number, number, number]; +const TAM_KUTU: Kutu = [0, 0, VIEW_W, VIEW_H]; + +/** + * Vurgu hücrelerinin çevresine, SVG ile aynı oranda ve en çok ODAK_ZOOM kat + * büyüten odak kutusu; harita dışına taşmayacak şekilde kaydırılır. + */ +function odakKutusu(hucreler: readonly IzgaraHucre[], zoom = ODAK_ZOOM): Kutu { + if (hucreler.length === 0) return TAM_KUTU; + 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; + } + const x0 = (q0 - ODAK_PAY) * CELL, x1 = (q1 + 1 + ODAK_PAY) * CELL; + const y0 = (r0 - ODAK_PAY) * CELL, y1 = (r1 + 1 + ODAK_PAY) * CELL; + const oran = VIEW_W / VIEW_H; + 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; + const x = Math.min(Math.max(cx - w / 2, 0), VIEW_W - w); + 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 opacityFor = (x: number, y: number) => @@ -69,7 +113,7 @@ function markupUret( zemin: readonly IzgaraHucre[], vurgu: readonly IzgaraHucre[], isaret: IzgaraHucre | null, -): string { +): Harita { const parts: string[] = []; const vurguSet = new Set(vurgu.map(([q, r]) => `${q},${r}`)); const enAlt: number[] = Array(SUTUN).fill(-1); @@ -105,11 +149,17 @@ function markupUret( ); } parts.push(""); - return parts.join(""); + // Odak: il kareleri; ülke haritasında şehir işareti (2×2) ya da ülkenin kendisi + const odak = isaret + ? odakKutusu([isaret, [isaret[0] + 1, isaret[1] + 1]], 1.8) + : odakKutusu(vurgu); + return { html: parts.join(""), odak }; } -const onbellek = new Map(); -function markup(anahtar: string, uret: () => string): string { +type Harita = { html: string; odak: Kutu }; + +const onbellek = new Map(); +function markup(anahtar: string, uret: () => Harita): Harita { let m = onbellek.get(anahtar); if (!m) { m = uret(); @@ -118,17 +168,50 @@ function markup(anahtar: string, uret: () => string): string { return m; } +const kutuMetni = (k: Kutu) => k.map((n) => n.toFixed(1)).join(" "); + function PikselSvg({ - html, + harita, etiket, className, }: { - html: string; + harita: Harita; etiket: string; className?: string; }) { const svgRef = useRef(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 { odak } = harita; + useEffect(() => { + const svg = svgRef.current; + if (!svg || odak === TAM_KUTU) return; + 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, { + delay: ODAK_GECIKME, + duration: ODAK_SURE, + ease: [...ODAK_EASE], + 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); + }; + }, [odak]); + return ( ); } @@ -154,10 +237,10 @@ export function TurkiyePikselHaritasi({ etiket: string; className?: string; }) { - const html = markup(`tr:${il}`, () => + const harita = markup(`tr:${il}`, () => markupUret(TUM_IL_HUCRELERI, IL_HUCRELERI[il] ?? [], null), ); - return ; + return ; } /** @@ -176,7 +259,7 @@ export function UlkePikselHaritasi({ etiket: string; className?: string; }) { - const html = markup( + const harita = markup( `ulke:${ulke}:${sehir ? `${Math.round(sehir.x)},${Math.round(sehir.y)}` : "-"}`, () => { const h = ULKE_HUCRELERI[ulke]; @@ -188,7 +271,7 @@ export function UlkePikselHaritasi({ return markupUret(h.baglam, h.ulke, null); }, ); - return ; + return ; } /** Şehir sayfası: il değerinden Türkiye ya da ülke haritasını seçer. */