"use client"; /** * Hero'daki (TurkeyPixelMap) imleç ısı fırçasının ortak hook hali. * `data-px` işaretli rect'ler imleç yaklaşınca anında turuncuya ısınır, * uzaklaşınca yavaşça kendi rengine soğur (asimetrik zamanlama). * * SVG katmanları çoğunlukla pointer-events-none olduğundan imleç window * üzerinden izlenir ve viewBox koordinatına çevrilir. Beklenen veri * öznitelikleri: data-px, data-cx, data-cy, data-op (taban opaklık). * * Landing'de 16 örnek yaşar: her örneğin kendi window dinleyicisi yerine * modül seviyesinde TEK pointermove dinleyicisi + rAF birleştirmesi kullanılır; * kare başına en fazla bir tarama yapılır, imleçten uzak örnekler hiç * piksel iterasyonuna girmez. */ import { useEffect, type RefObject } from "react"; // Bilinçli olarak TAM motor: mini'nin WAAPI animate'i SVG fill/fillOpacity // geçişlerini farklı örnekliyor ve ısınma/soğuma hissi bozuluyor (denendi, // geri alındı). motion/mini'ye çevirme. import { animate, type AnimationPlaybackControls } from "motion"; const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)"; const HOT_COLOR = "#f97316"; // orange-500 const HOT_OPACITY = 0.85; const HEAT_DURATION = 0.15; // ısınma: anlık geri bildirim const COOL_DURATION = 1.4; // soğuma: iz gibi yavaşça söner // Kırpışma: imleç olmadan, rastgele tek piksel ısınıp söner. Yormasın diye // seyrek ve yumuşak — aynı anda ekranda bir-iki sıcak piksel olur. const KIRPISMA_BEKLEME = 1500; // ms; sayfa otursun const KIRPISMA_ARALIK = [500, 1200]; // ms; iki kırpışma arası (rastgele) const KIRPISMA_ISINMA = 0.5; // imleçtekinden yavaş: göz kırpar, çakmaz const KIRPISMA_TUTMA = 900; // ms; ısınma + kısa bekleyiş, sonra soğur type HeatTarget = { el: SVGRectElement; cx: number; cy: number; baseOpacity: number; }; type HeatInstance = { svg: SVGSVGElement; radius: number; /** preserveAspectRatio "slice" mi (kabı doldurur) */ slice: boolean; baseColor: string; pixels: HeatTarget[]; hot: Set; controls: Map; }; const instances = new Set(); let sonOlay: PointerEvent | null = null; let rafId = 0; function calistir( inst: HeatInstance, el: SVGRectElement, to: { fill: string; fillOpacity: number }, duration: number, ) { inst.controls.get(el)?.stop(); inst.controls.set(el, animate(el, to, { duration, ease: "easeOut" })); } function tara() { rafId = 0; const e = sonOlay; if (!e) return; for (const inst of instances) { const rect = inst.svg.getBoundingClientRect(); if (rect.width === 0) continue; // 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 || !vb.height) continue; // 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; // İmleç fırça menzilinin tamamen dışındaysa ve sıcak piksel yoksa // bu örnek için hiç hesap yapma if ( inst.hot.size === 0 && (e.clientX < rect.left - pad || e.clientX > rect.right + pad || e.clientY < rect.top - pad || e.clientY > rect.bottom + pad) ) { continue; } const x = vb.x + (e.clientX - rect.left - ox) / scale; const y = vb.y + (e.clientY - rect.top - oy) / scale; for (const p of inst.pixels) { const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2; const isHot = d2 <= inst.radius * inst.radius; if (isHot && !inst.hot.has(p.el)) { inst.hot.add(p.el); calistir( inst, p.el, { fill: HOT_COLOR, fillOpacity: HOT_OPACITY }, HEAT_DURATION, ); } else if (!isHot && inst.hot.has(p.el)) { inst.hot.delete(p.el); calistir( inst, p.el, { fill: inst.baseColor, fillOpacity: p.baseOpacity }, COOL_DURATION, ); } } } } function onMove(e: PointerEvent) { sonOlay = e; if (!rafId) rafId = requestAnimationFrame(tara); } function kaydol(inst: HeatInstance) { if (instances.size === 0) { window.addEventListener("pointermove", onMove, { passive: true }); } instances.add(inst); } function ayril(inst: HeatInstance) { instances.delete(inst); inst.controls.forEach((c) => c.stop()); if (instances.size === 0) { window.removeEventListener("pointermove", onMove); if (rafId) cancelAnimationFrame(rafId); rafId = 0; sonOlay = null; } } /** Rastgele pikselleri seyrek aralıklarla ısıtıp soğutur; durdurucu döner. * Fırçayla aynı `calistir`/`hot` üzerinden çalışır: imlecin altındaki * piksele dokunmaz, fırça da kırpışan pikseli devralabilir. Harita ekran * dışındayken ya da sekme arka plandayken bekler. */ function kirpismaBaslat(inst: HeatInstance) { const yanan = new Set(); const bekleyen = new Set(); let gorunur = true; const io = new IntersectionObserver(([g]) => { gorunur = g.isIntersecting; }); io.observe(inst.svg); let sayac = 0; const tik = () => { const [enAz, enCok] = KIRPISMA_ARALIK; sayac = window.setTimeout(tik, enAz + Math.random() * (enCok - enAz)); if (!gorunur || document.hidden) return; const p = inst.pixels[Math.floor(Math.random() * inst.pixels.length)]; if (inst.hot.has(p.el) || yanan.has(p.el)) return; yanan.add(p.el); calistir( inst, p.el, { fill: HOT_COLOR, fillOpacity: HOT_OPACITY }, KIRPISMA_ISINMA, ); const s = window.setTimeout(() => { bekleyen.delete(s); yanan.delete(p.el); if (inst.hot.has(p.el)) return; calistir( inst, p.el, { fill: inst.baseColor, fillOpacity: p.baseOpacity }, COOL_DURATION, ); }, KIRPISMA_TUTMA); bekleyen.add(s); }; sayac = window.setTimeout(tik, KIRPISMA_BEKLEME); return () => { window.clearTimeout(sayac); bekleyen.forEach((s) => window.clearTimeout(s)); io.disconnect(); }; } export function usePixelHeat( svgRef: RefObject, { /** Fırça yarıçapı, SVG (viewBox) biriminde */ radius = 34, /** İmleç olmadan da rastgele pikseller seyrekçe ısınıp sönsün (hero) */ kirpisma = false, }: { radius?: number; kirpisma?: boolean } = {}, ) { useEffect(() => { const svg = svgRef.current; if (!svg) return; // Dokunmatik cihazlarda hover yanlış tetiklenir; fırça imleçli cihazlara // saklanır. Kırpışma imleç istemez ama "hareketi azalt"ta kapalıdır. const imlecli = window.matchMedia(FINE_POINTER_QUERY).matches; const kirp = kirpisma && !window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (!imlecli && !kirp) return; if (!svg.viewBox.baseVal.width) return; const pixels: HeatTarget[] = Array.from( svg.querySelectorAll("[data-px]"), ).map((el) => ({ el, cx: Number(el.dataset.cx), cy: Number(el.dataset.cy), baseOpacity: Number(el.dataset.op), })); if (pixels.length === 0) return; const inst: HeatInstance = { svg, radius, slice: (svg.getAttribute("preserveAspectRatio") ?? "").includes("slice"), baseColor: getComputedStyle(svg).color, pixels, hot: new Set(), controls: new Map(), }; if (imlecli) kaydol(inst); const kirpismaDur = kirp ? kirpismaBaslat(inst) : null; return () => { kirpismaDur?.(); ayril(inst); }; }, [svgRef, radius, kirpisma]); }