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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user