All checks were successful
Deploy / deploy (push) Successful in 4m34s
- Izgara 72 → 56 sütun, serpinti kaldırıldı (Bilal: "piksel detaylarını azalt"). - Harita `doldur` (slice) ile SehirYuzeyi kartını kaplar, metin üstünde; iki katlı mask ile hiçbir kenarda kesik kare kalmaz (Bilal: "canvas net bir şekilde kesiliyor"). Telefonda altta, lg'de sağ yarıda belirir. - Yakınlaşma kutusu kabın oranında kurulur, il `odakHiza` ile kabın istenen noktasına gelir (şehir sayfası: lg sağ orta, telefon alt orta; konum kartı orta). - usePixelHeat meet/slice + ortalama ofsetini hesaba katar. - Üniversite konum kartı: kartı doldurur, radyal erime. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
180 lines
5.5 KiB
TypeScript
180 lines
5.5 KiB
TypeScript
"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
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
export function usePixelHeat(
|
||
svgRef: RefObject<SVGSVGElement | null>,
|
||
{
|
||
/** Fırça yarıçapı, SVG (viewBox) biriminde */
|
||
radius = 34,
|
||
}: { radius?: number } = {},
|
||
) {
|
||
useEffect(() => {
|
||
const svg = svgRef.current;
|
||
if (!svg) return;
|
||
// Dokunmatik cihazlarda hover yanlış tetiklenir; efekti imleçli cihazlara sakla
|
||
if (!window.matchMedia(FINE_POINTER_QUERY).matches) 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(),
|
||
};
|
||
kaydol(inst);
|
||
return () => ayril(inst);
|
||
}, [svgRef, radius]);
|
||
}
|