Files
kolaytercih/src/features/katalog/components/sehir-piksel-haritasi.tsx
bilalgursen 6690d3b779
All checks were successful
Deploy / deploy (push) Successful in 4m51s
fix(tasarim): üniversite haritası dört kartın arkasına sarkar; yakınlaşma sıçramasız yay, mask rampaları uzun
- Özet ızgarası relative isolate; konum haritası absolute katman (-inset-x-4
  -inset-y-8) olarak ızgaranın tamamını kaplar, üçüncü sütun yalnız yer tutar.
  lg'de sağdan sola, telefonda üst satırdan aşağı uzun rampalı mask.
- Yakınlaşma ease-out yerine spring bounce 0 (0,6 s gecikme, 1,6 s): sert
  başlangıç gitti. Şehir yüzeyi mask'ları da uzatıldı.
- DESIGN.md §4.17 güncellendi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 15:37:10 +03:00

310 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 56 sütun (her il en az 2×2 kare; Bilal, 24
* Eyl 2026: "piksel detaylarını azalt"), serpinti yok, bulunulan ilin
* 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
* 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) güçlü ease-out ile kayar — kareler büyür, il kadraja
* 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 { animate } from "motion";
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;
const VIEW_W = SUTUN * CELL;
const VIEW_H = SATIR * 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)
// Sıçramasız yay (Apple: damping 1): hızlı başlayan ease-out "sert"
// duruyordu (Bilal, 24 Eyl 2026: "geçişler sert"); yay sıfır hızla başlar,
// yumuşak ivmelenir ve sessizce oturur.
const ODAK_GECIKME = 0.6;
const ODAK_SURE = 1.6;
const ODAK_ZOOM = 2.4;
const ODAK_PAY = 2;
type Kutu = readonly [number, number, number, number];
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). */
export type OdakHiza = { readonly x: number; readonly y: number };
const ORTA: OdakHiza = { x: 0.5, y: 0.5 };
function sinirKutusu(hucreler: readonly IzgaraHucre[]): Sinir | null {
if (hucreler.length === 0) return null;
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;
}
return [
(q0 - ODAK_PAY) * CELL,
(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 h = w / oran;
if (h < y1 - y0) {
h = y1 - y0;
w = h * oran;
}
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
return [cx - w * hiza.x, cy - h * hiza.y, w, h];
}
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];
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,
): Harita {
const parts: string[] = [];
const vurguSet = new Set(vurgu.map(([q, r]) => `${q},${r}`));
for (const [q, r] of zemin) {
if (vurguSet.has(`${q},${r}`)) continue;
parts.push(zeminKare(q, r, opacityFor(q, r)));
}
// 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>");
// Odak: il kareleri; ülke haritasında şehir işareti (2×2) ya da ülkenin kendisi
const odak = isaret
? { sinir: sinirKutusu([isaret, [isaret[0] + 1, isaret[1] + 1]]), zoom: 1.8 }
: { sinir: sinirKutusu(vurgu), zoom: ODAK_ZOOM };
return { html: parts.join(""), ...odak };
}
type Harita = { html: string; sinir: Sinir | null; zoom: number };
const onbellek = new Map<string, Harita>();
function markup(anahtar: string, uret: () => Harita): Harita {
let m = onbellek.get(anahtar);
if (!m) {
m = uret();
onbellek.set(anahtar, m);
}
return m;
}
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({
harita,
etiket,
className,
doldur = false,
odakHiza,
}: HaritaProps & { harita: Harita }) {
const svgRef = useRef<SVGSVGElement>(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 { sinir, zoom } = harita;
const hizaLg = odakHiza?.lg ?? ORTA;
const hizaDar = odakHiza?.dar ?? ORTA;
useEffect(() => {
const svg = svgRef.current;
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));
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
yaz(odak);
return () => yaz(TAM_KUTU);
}
const bas = TAM_KUTU;
const kontrol = animate(0, 1, {
type: "spring",
bounce: 0,
delay: ODAK_GECIKME,
duration: ODAK_SURE,
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);
};
}, [sinir, zoom, hizaLg, hizaDar]);
return (
<svg
ref={svgRef}
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
preserveAspectRatio={doldur ? "xMidYMid slice" : "xMidYMid meet"}
className={cn(doldur ? "h-full w-full" : "h-auto w-full", "text-primary", className)}
role="img"
aria-label={etiket}
// Statik, deterministik markup — her render'da yeniden kurulmaz
dangerouslySetInnerHTML={{ __html: harita.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,
...props
}: HaritaProps & { il: string }) {
const harita = markup(`tr:${il}`, () =>
markupUret(TUM_IL_HUCRELERI, IL_HUCRELERI[il] ?? [], null),
);
return <PikselSvg harita={harita} {...props} />;
}
/**
* Ü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,
...props
}: HaritaProps & {
ulke: UlkeKodu;
/** viewBox koordinatı (harita-ulkeler'deki proje ile) */
sehir: { x: number; y: number } | null;
}) {
const harita = 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 harita={harita} {...props} />;
}
/** Şehir sayfası: il değerinden Türkiye ya da ülke haritasını seçer. */
export function SehirPikselHaritasi({
il,
ad,
...props
}: Omit<HaritaProps, "etiket"> & {
/** DB biçiminde il ("İSTANBUL", "LEFKOŞA", "KIBRIS") */
il: string;
/** Görünen ad (etiket için) */
ad: string;
}) {
if (IL_HUCRELERI[il]) {
return (
<TurkiyePikselHaritasi
il={il}
etiket={`${ad} Türkiye haritasında`}
{...props}
/>
);
}
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`}
{...props}
/>
);
}