Some checks failed
Deploy / deploy (push) Has been cancelled
- pixel-decor: PixelField/PixelDivider piksel üretimi useMemo([seed]) — 504 iterasyon + ~150 element çekmece reorder'ının her karesinde yeniden üretiliyordu - rapor-listesi: acikSira effect'i önceki-prop desenine döndü (çift render ve eslint-disable kalktı), Set lazy init, tekIl useMemo - program-tablosu: 3 dilimin spread'i useMemo - typing-animation: displayedText state yerine türetme; kaynak reset'i render sırasında — tick başına state yazımı ve effect bağımlılığı azaldı - site-top-banner: saniyelik geri sayım tiki startTransition'da - program-liste-verileri: sparkline 6 dizi geçişi tek döngüde - secimlerim-paneli: SecimSatiri profili prop'tan alır + memo (24 ayrı store aboneliği kalktı) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
293 lines
7.5 KiB
TypeScript
293 lines
7.5 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useRef, type ReactNode } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
import { usePixelHeat } from "@/components/use-pixel-heat";
|
||
|
||
/**
|
||
* Hero'daki dot-matrix dilini (TurkeyPixelMap) bölüm ayraçlarına ve
|
||
* dekorlara taşıyan piksel süsleri. Rastgelelik yerine deterministik
|
||
* hash kullanılır ki sunucu ve istemci aynı deseni üretsin.
|
||
*
|
||
* Hepsi hero'daki imleç ısı fırçasına katılır (usePixelHeat): imleç
|
||
* yaklaşınca pikseller turuncuya ısınır, uzaklaşınca yavaşça soğur.
|
||
* Turuncu vurgu (accent) pikselleri hero'daki şehirler gibi fırçaya
|
||
* katılmaz.
|
||
*/
|
||
|
||
// Hero ile aynı ızgara ölçüleri
|
||
const CELL = 10;
|
||
const DOT = 7.4;
|
||
const INSET = (CELL - DOT) / 2;
|
||
|
||
// Hero ile aynı doku paleti
|
||
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];
|
||
|
||
// Deterministik sözde-rastgele: aynı girdi her ortamda aynı deseni verir
|
||
const hash = (x: number, y: number, seed: number) => {
|
||
const n = Math.sin(x * 127.1 + y * 311.7 + seed * 74.7) * 43758.5453;
|
||
return n - Math.floor(n);
|
||
};
|
||
|
||
function pixelRect(
|
||
x: number,
|
||
y: number,
|
||
extra: { key: string; accent?: boolean; accentDelay?: number },
|
||
) {
|
||
const shared = {
|
||
x: x * CELL + INSET,
|
||
y: y * CELL + INSET,
|
||
width: DOT,
|
||
height: DOT,
|
||
rx: 2,
|
||
};
|
||
if (extra.accent) {
|
||
return (
|
||
<rect
|
||
key={extra.key}
|
||
{...shared}
|
||
className="animate-pulse fill-orange-500/70 motion-reduce:animate-none"
|
||
style={{
|
||
animationDuration: "3.6s",
|
||
animationDelay: `${extra.accentDelay ?? 0}s`,
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
const op = opacityFor(x, y);
|
||
return (
|
||
<rect
|
||
key={extra.key}
|
||
{...shared}
|
||
data-px=""
|
||
data-cx={x * CELL + CELL / 2}
|
||
data-cy={y * CELL + CELL / 2}
|
||
data-op={op}
|
||
fill="currentColor"
|
||
fillOpacity={op}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Geniş alanlar için seyrek piksel zemini (ör. koyu CTA bandı).
|
||
* İçeriğin oturduğu orta bölge tamamen boş bırakılır; pikseller
|
||
* kenarlara doğru yoğunlaşır.
|
||
*/
|
||
export function PixelField({
|
||
seed = 7,
|
||
className,
|
||
}: {
|
||
seed?: number;
|
||
className?: string;
|
||
}) {
|
||
const svgRef = useRef<SVGSVGElement>(null);
|
||
usePixelHeat(svgRef);
|
||
|
||
const COLS = 42;
|
||
const ROWS = 12;
|
||
|
||
// Çıktı seed'e göre deterministik; 504 iterasyon + ~150 element üretimini
|
||
// her render'da (ör. çekmece reorder'ında) tekrarlama (rendering-hoist-jsx)
|
||
const pixels = useMemo(() => {
|
||
const cx = (COLS - 1) / 2;
|
||
const cy = (ROWS - 1) / 2;
|
||
const maxD = Math.sqrt(cx * cx + cy * cy);
|
||
|
||
const sonuc: ReactNode[] = [];
|
||
let accentIdx = 0;
|
||
|
||
// Hero serpintisiyle aynı kural: içeriğin oturduğu orta kolon
|
||
// (genişliğin %22–78 bandı) tüm yükseklik boyunca tamamen boş kalır
|
||
const BAND_MIN = COLS * 0.22;
|
||
const BAND_MAX = COLS * 0.78;
|
||
|
||
for (let y = 0; y < ROWS; y++) {
|
||
for (let x = 0; x < COLS; x++) {
|
||
if (x > BAND_MIN && x < BAND_MAX) continue;
|
||
// Kenarlara doğru artan doluluk
|
||
const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2) / maxD;
|
||
const density = 0.1 + 0.42 * d * d;
|
||
if (hash(x, y, seed) > density) continue;
|
||
const accent = hash(x, y, seed + 99) > 0.96;
|
||
sonuc.push(
|
||
pixelRect(x, y, {
|
||
key: `${x}-${y}`,
|
||
accent,
|
||
accentDelay: accent ? (accentIdx++ % 8) * 0.45 : 0,
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
return sonuc;
|
||
}, [seed]);
|
||
|
||
return (
|
||
<svg
|
||
ref={svgRef}
|
||
viewBox={`0 0 ${COLS * CELL} ${ROWS * CELL}`}
|
||
preserveAspectRatio="xMidYMid slice"
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
className={cn("h-full w-full", className)}
|
||
>
|
||
{pixels}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Bölümler arasında sert border çizgileri yerine kullanılan, ortada
|
||
* yoğunlaşıp kenarlara doğru dağılan piksel ayraç. Hero'nun dot-matrix
|
||
* dilini bölüm geçişlerine taşır.
|
||
*/
|
||
export function PixelDivider({
|
||
seed = 5,
|
||
className,
|
||
}: {
|
||
seed?: number;
|
||
className?: string;
|
||
}) {
|
||
const svgRef = useRef<SVGSVGElement>(null);
|
||
usePixelHeat(svgRef);
|
||
|
||
const COLS = 12;
|
||
const ROWS = 2;
|
||
|
||
// Deterministik çıktı — render başına yeniden üretme (PixelField ile aynı)
|
||
const pixels = useMemo(() => {
|
||
const cx = (COLS - 1) / 2;
|
||
const sonuc: ReactNode[] = [];
|
||
let accentPlaced = false;
|
||
|
||
for (let y = 0; y < ROWS; y++) {
|
||
for (let x = 0; x < COLS; x++) {
|
||
// Az sayıda, iri piksel: ortada yoğun, kenara doğru seyrelen küçük küme
|
||
const dx = Math.abs(x - cx) / cx;
|
||
const rowFalloff = y === 0 ? 0.9 : 0.35;
|
||
const density = (1 - dx) ** 1.1 * rowFalloff;
|
||
if (hash(x, y, seed) > density) continue;
|
||
const accent = !accentPlaced && hash(x, y, seed + 99) > 0.8;
|
||
if (accent) accentPlaced = true;
|
||
sonuc.push(
|
||
pixelRect(x, y, {
|
||
key: `${x}-${y}`,
|
||
accent,
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
return sonuc;
|
||
}, [seed]);
|
||
|
||
return (
|
||
<svg
|
||
ref={svgRef}
|
||
viewBox={`0 0 ${COLS * CELL} ${ROWS * CELL}`}
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
className={cn("h-auto w-full text-primary", className)}
|
||
>
|
||
{pixels}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/** Alt sayfa başlıklarını içerikten ayıran kompakt piksel imzası. */
|
||
export function PagePixelDivider({
|
||
seed = 5,
|
||
className,
|
||
}: {
|
||
seed?: number;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<div aria-hidden="true" className={cn("w-40", className)}>
|
||
<PixelDivider seed={seed} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Küçük sabit süslerde (mark/korner) data-px öznitelikli rect. */
|
||
function fixedPixel(x: number, y: number, opacity: number) {
|
||
return (
|
||
<rect
|
||
x={x + INSET}
|
||
y={y + INSET}
|
||
width={DOT}
|
||
height={DOT}
|
||
rx={2}
|
||
data-px=""
|
||
data-cx={x + CELL / 2}
|
||
data-cy={y + CELL / 2}
|
||
data-op={opacity}
|
||
fill="currentColor"
|
||
fillOpacity={opacity}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/** Eyebrow rozetlerinde kullanılan 2×2'lik minik piksel imzası. */
|
||
export function PixelMark({ className }: { className?: string }) {
|
||
const svgRef = useRef<SVGSVGElement>(null);
|
||
usePixelHeat(svgRef, { radius: 24 });
|
||
|
||
return (
|
||
<svg
|
||
ref={svgRef}
|
||
viewBox="0 0 20 20"
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
className={cn("size-3.5 shrink-0", className)}
|
||
>
|
||
{fixedPixel(0, 0, 0.35)}
|
||
<rect x={11.3} y={1.3} width={7.4} height={7.4} rx={2} className="fill-orange-500" fillOpacity={0.85} />
|
||
{fixedPixel(0, 10, 0.6)}
|
||
{fixedPixel(10, 10, 0.18)}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/** Kart köşelerine iliştirilen sessiz piksel kümesi. */
|
||
export function PixelCorner({ className }: { className?: string }) {
|
||
const svgRef = useRef<SVGSVGElement>(null);
|
||
usePixelHeat(svgRef, { radius: 28 });
|
||
|
||
return (
|
||
<svg
|
||
ref={svgRef}
|
||
viewBox="0 0 30 30"
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
className={cn("size-7 text-primary", className)}
|
||
>
|
||
{fixedPixel(20, 0, 0.5)}
|
||
{fixedPixel(10, 0, 0.14)}
|
||
{fixedPixel(20, 10, 0.26)}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/** Bölüm başlıklarının üstünde duran piksel imzalı eyebrow rozeti. */
|
||
export function SectionEyebrow({
|
||
children,
|
||
className,
|
||
}: {
|
||
children: ReactNode;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-primary",
|
||
className,
|
||
)}
|
||
>
|
||
<PixelMark />
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|