Files
kolaytercih/src/components/use-pixel-heat.ts
bilalgursen ab0b3a047f
All checks were successful
Deploy / deploy (push) Successful in 7m52s
Piksel harita ve logo animasyonu iyileştirmeleri
Şehir/Türkiye piksel haritası görünüme girince oynar; animasyonlu-mark,
console-asr, use-pixel-heat ve cta-sira-form güncellendi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 14:09:44 +03:00

246 lines
7.9 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";
/**
* 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<SVGRectElement>;
controls: Map<SVGRectElement, AnimationPlaybackControls>;
};
const instances = new Set<HeatInstance>();
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<SVGRectElement>();
const bekleyen = new Set<number>();
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<SVGSVGElement | null>,
{
/** 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<SVGRectElement>("[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]);
}