feat(tasarim): şehir haritası yüklenince ile doğru yakınlaşır

- viewBox 0,7 s gecikmeyle 1,1 s güçlü ease-out ile ilin çevresindeki kutuya
  kayar (en çok 2,4 kat; ülke haritasında şehir işaretine 1,8 kat); kutu SVG
  oranında ve harita dışına taşmaz. Hareketi azalt: doğrudan odak.
- usePixelHeat viewBox'ı her taramada canlı okur; yakınlaşmış haritada da
  imleç doğru kareyi ısıtır (hero davranışı değişmez).
- DESIGN.md §4.17 yakınlaşma notu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-24 15:02:28 +03:00
parent eefac56659
commit 6e057aa0f5
3 changed files with 110 additions and 19 deletions

View File

@@ -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ı `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.
- **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 ## 5. Yerleşim

View File

@@ -36,7 +36,6 @@ type HeatTarget = {
type HeatInstance = { type HeatInstance = {
svg: SVGSVGElement; svg: SVGSVGElement;
viewW: number;
radius: number; radius: number;
baseColor: string; baseColor: string;
pixels: HeatTarget[]; pixels: HeatTarget[];
@@ -65,7 +64,12 @@ function tara() {
for (const inst of instances) { for (const inst of instances) {
const rect = inst.svg.getBoundingClientRect(); const rect = inst.svg.getBoundingClientRect();
if (rect.width === 0) continue; 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; 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
@@ -78,8 +82,8 @@ function tara() {
) { ) {
continue; continue;
} }
const x = (e.clientX - rect.left) / scale; const x = vb.x + (e.clientX - rect.left) / scale;
const y = (e.clientY - rect.top) / scale; const y = vb.y + (e.clientY - rect.top) / 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;
@@ -140,8 +144,7 @@ export function usePixelHeat(
// Dokunmatik cihazlarda hover yanlış tetiklenir; efekti imleçli cihazlara sakla // Dokunmatik cihazlarda hover yanlış tetiklenir; efekti imleçli cihazlara sakla
if (!window.matchMedia(FINE_POINTER_QUERY).matches) return; if (!window.matchMedia(FINE_POINTER_QUERY).matches) return;
const viewW = svg.viewBox.baseVal.width; if (!svg.viewBox.baseVal.width) return;
if (!viewW) return;
const pixels: HeatTarget[] = Array.from( const pixels: HeatTarget[] = Array.from(
svg.querySelectorAll<SVGRectElement>("[data-px]"), svg.querySelectorAll<SVGRectElement>("[data-px]"),
@@ -155,7 +158,6 @@ export function usePixelHeat(
const inst: HeatInstance = { const inst: HeatInstance = {
svg, svg,
viewW,
radius, radius,
baseColor: getComputedStyle(svg).color, baseColor: getComputedStyle(svg).color,
pixels, pixels,

View File

@@ -12,9 +12,16 @@
* 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
* hydrate edilmez; server ve client aynı çıktıyı verir. * 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 { usePixelHeat } from "@/components/use-pixel-heat";
import { ilUlkeHaritasi, type UlkeKodu } from "@/lib/harita-ulkeler"; import { ilUlkeHaritasi, type UlkeKodu } from "@/lib/harita-ulkeler";
import { import {
@@ -37,6 +44,43 @@ const VIEW_W = SUTUN * CELL;
const VIEW_H = (SATIR + SERPINTI) * CELL; const VIEW_H = (SATIR + SERPINTI) * 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)
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 OPACITIES = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
const opacityFor = (x: number, y: number) => const opacityFor = (x: number, y: number) =>
@@ -69,7 +113,7 @@ function markupUret(
zemin: readonly IzgaraHucre[], zemin: readonly IzgaraHucre[],
vurgu: readonly IzgaraHucre[], vurgu: readonly IzgaraHucre[],
isaret: IzgaraHucre | null, isaret: IzgaraHucre | null,
): string { ): 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); const enAlt: number[] = Array(SUTUN).fill(-1);
@@ -105,11 +149,17 @@ function markupUret(
); );
} }
parts.push("</g>"); parts.push("</g>");
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<string, string>(); type Harita = { html: string; odak: Kutu };
function markup(anahtar: string, uret: () => string): string {
const onbellek = new Map<string, Harita>();
function markup(anahtar: string, uret: () => Harita): Harita {
let m = onbellek.get(anahtar); let m = onbellek.get(anahtar);
if (!m) { if (!m) {
m = uret(); m = uret();
@@ -118,17 +168,50 @@ function markup(anahtar: string, uret: () => string): string {
return m; return m;
} }
const kutuMetni = (k: Kutu) => k.map((n) => n.toFixed(1)).join(" ");
function PikselSvg({ function PikselSvg({
html, harita,
etiket, etiket,
className, className,
}: { }: {
html: string; harita: Harita;
etiket: string; etiket: string;
className?: string; 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ı
// 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 ( return (
<svg <svg
ref={svgRef} ref={svgRef}
@@ -137,7 +220,7 @@ function PikselSvg({
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
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: harita.html }}
/> />
); );
} }
@@ -154,10 +237,10 @@ export function TurkiyePikselHaritasi({
etiket: string; etiket: string;
className?: string; className?: string;
}) { }) {
const html = 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 html={html} etiket={etiket} className={className} />; return <PikselSvg harita={harita} etiket={etiket} className={className} />;
} }
/** /**
@@ -176,7 +259,7 @@ export function UlkePikselHaritasi({
etiket: string; etiket: string;
className?: string; className?: string;
}) { }) {
const html = 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)}` : "-"}`,
() => { () => {
const h = ULKE_HUCRELERI[ulke]; const h = ULKE_HUCRELERI[ulke];
@@ -188,7 +271,7 @@ export function UlkePikselHaritasi({
return markupUret(h.baglam, h.ulke, null); return markupUret(h.baglam, h.ulke, null);
}, },
); );
return <PikselSvg html={html} etiket={etiket} className={className} />; return <PikselSvg harita={harita} etiket={etiket} className={className} />;
} }
/** Ş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. */