Files
kolaytercih/src/components/pixel-decor.tsx
bilalgursen 3c995402c6 feat(ui): Arc bileşenleri, landing bölümleri ve bildirim yığını
Sonner/vaul yerine kendi bildirim yığını ve alt sayfa; çip grubu, kart
destesi, kaydırma eylemi, yazı dönüşümü, bölme tutamacı; landing'e program
duvarı, süzülen haplar ve veri akışı; yaylar tek dosyada (lib/hareket.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 17:12:53 +03:00

598 lines
17 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";
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
type RefObject,
} 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}
/>
);
}
/**
* CTA yüzeylerinin piksel zemini: büyüklü küçüklü kareler, masonry gibi
* hafif kaymış (Bilal, 24 Eyl 2026: "arka planda büyüklü küçüklü, masonry
* şeklinde"). Kabın **gerçek ölçüsüne** göre üretilir (ResizeObserver):
* sabit bir çizimi kaplayacak şekilde ölçeklemek yüksek kartlarda görünen
* kısmı boş banda düşürüyordu ("hiç doku yok"). Yalnız kenar çerçevesinde,
* orta tamamen boş; `haric` verilen öğenin (ör. 24'lük piksel tablosu)
* çevresi de boş kalır. Küçük (tek hücrelik) kare **yok** (Bilal, 24 Eyl
* 2026: "çok fazla piksel, küçük olanlar hiç olmasın"): 4×4 hücrelik makro
* hücrelere 2–4 hücrelik tek kare düşer ve makro hücre içinde kaydırılır:
* çakışmaz ama hizaya da girmez (§1.1 "doğal duruş"). Durağan zemin:
* imleç ısı fırçasına katılmaz.
* Sunucuda boş çizilir, ölçülünce belirir (dekor; aria-hidden).
*/
export function PixelMasonry({
seed = 7,
yogunluk = 1,
kenar = 0.3,
haric,
className,
}: {
seed?: number;
/** Doluluk çarpanı; 1 varsayılan, 0.6 seyrek */
yogunluk?: number;
/** Kenar çerçevesinin genişliği: kısa kenarın oranı (0.3 = %30) */
kenar?: number;
/** Çevresi boş bırakılacak öğe (kreatif, iri sayı) */
haric?: RefObject<HTMLElement | null>;
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null);
const [olcu, setOlcu] = useState<{
w: number;
h: number;
hx: number;
hy: number;
hw: number;
hh: number;
} | null>(null);
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
const olc = () => {
const r = svg.getBoundingClientRect();
const hr = haric?.current?.getBoundingClientRect();
const sonraki = {
w: Math.round(r.width),
h: Math.round(r.height),
hx: hr ? Math.round(hr.left - r.left) : 0,
hy: hr ? Math.round(hr.top - r.top) : 0,
hw: hr ? Math.round(hr.width) : 0,
hh: hr ? Math.round(hr.height) : 0,
};
setOlcu((onceki) =>
onceki &&
onceki.w === sonraki.w &&
onceki.h === sonraki.h &&
onceki.hx === sonraki.hx &&
onceki.hy === sonraki.hy &&
onceki.hw === sonraki.hw &&
onceki.hh === sonraki.hh
? onceki
: sonraki,
);
};
olc();
const ro = new ResizeObserver(olc);
ro.observe(svg);
const h = haric?.current;
if (h) ro.observe(h);
return () => ro.disconnect();
}, [haric]);
const MAKRO = 4;
const PAY = 14; // hariç öğenin çevresinde bırakılan boşluk (px)
const pixels = useMemo(() => {
if (!olcu || olcu.w === 0 || olcu.h === 0) return null;
const { w, h, hx, hy, hw, hh } = olcu;
const cols = Math.ceil(w / CELL);
const rows = Math.ceil(h / CELL);
const kisa = Math.min(w, h);
const sonuc: ReactNode[] = [];
let accentIdx = 0;
const kare = (
x: number,
y: number,
n: number,
key: string,
accent: boolean,
) => {
const boy = n * CELL - 2 * INSET;
const shared = {
x: x * CELL + INSET,
y: y * CELL + INSET,
width: boy,
height: boy,
rx: 2 * n,
};
if (accent) {
return (
<rect
key={key}
{...shared}
className="animate-pulse fill-orange-500/70 motion-reduce:animate-none"
style={{
animationDuration: "3.6s",
animationDelay: `${(accentIdx++ % 8) * 0.45}s`,
}}
/>
);
}
// İri kare daha silik: yazının yanında bağırmasın
const op = opacityFor(x, y) * (n === 2 ? 0.9 : n === 3 ? 0.7 : 0.55);
return (
<rect key={key} {...shared} fill="currentColor" fillOpacity={op} />
);
};
const haricte = (px: number, py: number) =>
hw > 0 &&
px >= hx - PAY &&
px <= hx + hw + PAY &&
py >= hy - PAY &&
py <= hy + hh + PAY;
for (let my = 0; my < rows / MAKRO; my++) {
for (let mx = 0; mx < cols / MAKRO; mx++) {
const ox = mx * MAKRO;
const oy = my * MAKRO;
const cxPx = (ox + MAKRO / 2) * CELL;
const cyPx = (oy + MAKRO / 2) * CELL;
if (haricte(cxPx, cyPx)) continue;
// En yakın kenara uzaklık, kısa kenarın oranı olarak (0 kenar, 0.5 orta)
const k = Math.min(cxPx, w - cxPx, cyPx, h - cyPx) / kisa;
// Yalnız çerçevede, kenara doğru artan doluluk; orta boş
if (k >= kenar) continue;
const density = (0.06 + 0.5 * (1 - k / kenar) ** 1.6) * yogunluk;
if (hash(mx, my, seed) > density) continue;
const r = hash(mx, my, seed + 7);
// 2–4 hücre: orta boy sık, iri seyrek
const n = r < 0.5 ? 2 : r < 0.85 ? 3 : 4;
const serbest = MAKRO - n;
const kx = ox + Math.floor(hash(mx, my, seed + 11) * (serbest + 1));
const ky = oy + Math.floor(hash(mx, my, seed + 13) * (serbest + 1));
const accent = n === 2 && hash(mx, my, seed + 99) > 0.95;
sonuc.push(kare(kx, ky, n, `m-${mx}-${my}`, accent));
}
}
return sonuc;
}, [olcu, seed, yogunluk, kenar]);
return (
<svg
ref={svgRef}
viewBox={olcu ? `0 0 ${olcu.w} ${olcu.h}` : undefined}
preserveAspectRatio="xMinYMin slice"
aria-hidden="true"
focusable="false"
className={cn(
"h-full w-full transition-opacity duration-500 motion-reduce:transition-none",
pixels ? "opacity-100" : "opacity-0",
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 (PixelMasonry 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}
/>
);
}
/** PixelMark pikseli: kendi kendine, sırası geldiğinde ısınıp sönen rect. */
function markPixel(x: number, y: number, opacity: number, delay: number) {
return (
<rect
x={x + INSET}
y={y + INSET}
width={DOT}
height={DOT}
rx={2}
className="animate-pixel-isi motion-reduce:animate-none"
fill="currentColor"
fillOpacity={opacity}
style={
{ "--px-op": opacity, animationDelay: `${delay}s` } as CSSProperties
}
/>
);
}
/**
* Eyebrow rozetlerinde kullanılan 2×2'lik minik piksel imzası.
* Pikseller imleç fırçasına katılmak yerine saat yönünde sırayla
* ısınıp söner; ikisi aynı fill/fillOpacity'yi sürdüğü için çakışır.
*/
export function PixelMark({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 20 20"
aria-hidden="true"
focusable="false"
className={cn("size-3.5 shrink-0", className)}
>
{markPixel(0, 0, 0.35, 0)}
<rect
x={11.3}
y={1.3}
width={7.4}
height={7.4}
rx={2}
className="animate-pixel-isi-accent fill-orange-500 motion-reduce:animate-none"
fillOpacity={0.85}
style={{ animationDelay: "0.6s" }}
/>
{markPixel(10, 10, 0.18, 1.2)}
{markPixel(0, 10, 0.6, 1.8)}
</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>
);
}
/**
* Bir sıranın yerini tek satır piksel şeridinde gösteren sessiz gösterge
* (ör. URAP'ta 198 üniversite içinde 5.). Şerit soldan (1.) sağa
* (sonuncu) dilimlere bölünür; sıranın düştüğü dilim hero'daki şehirler
* gibi turuncu yanar, geri kalanı hero dokusunda durur ve imleç fırçasına
* katılır. Dekoratiftir: anlamı yanındaki metin taşır (renk tek başına
* anlam taşımaz), bu yüzden aria-hidden.
*/
export function PixelSira({
sira,
toplam,
hucre = 8,
zemin = "acik",
className,
}: {
sira: number;
toplam: number;
hucre?: number;
/** "koyu": renkli dolu zemin üstünde (ör. turuncu gösterge karesi) —
* işaret beyaz yanar, doku zeminin üstünde beyaz kalır. */
zemin?: "acik" | "koyu";
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null);
// Isı fırçası turuncuya ısıtır; turuncu zeminde görünmez, orada kapalı.
usePixelHeat(svgRef, { radius: zemin === "koyu" ? 0 : 28 });
const yer = Math.min(
hucre - 1,
Math.max(0, Math.floor(((sira - 1) / toplam) * hucre)),
);
return (
<svg
ref={svgRef}
viewBox={`0 0 ${hucre * CELL} ${CELL}`}
aria-hidden="true"
focusable="false"
className={cn("h-3.5 w-auto shrink-0 text-primary", className)}
>
{Array.from({ length: hucre }, (_, i) =>
i === yer ? (
<rect
key={i}
x={i * CELL + INSET}
y={INSET}
width={DOT}
height={DOT}
rx={2}
className={zemin === "koyu" ? "fill-white" : "fill-orange-500"}
fillOpacity={zemin === "koyu" ? 1 : 0.85}
/>
) : (
<g key={i}>{fixedPixel(i * CELL, 0, opacityFor(i, 0) + 0.1)}</g>
),
)}
</svg>
);
}
/**
* Piksel sahne (DESIGN.md §4.31): hero'daki dot-matrix Türkiye'nin dilini
* küçük resimlere taşır. Harita gibi satır satır bir ızgaradan çizilir:
* `#` doku pikseli, `-` silik piksel, `o` turuncu vurgu (hero'daki şehirler
* gibi nabız atar), başka her karakter boş. Doku pikselleri imleç ısı
* fırçasına katılır. Resim kartın söylediğini gösterir (süs değil); anlamı
* yanındaki başlık taşır, bu yüzden aria-hidden.
*/
export function PixelSahne({
izgara,
ton = "doku",
yaricap = 26,
className,
}: {
izgara: readonly string[];
/** "doku": hero haritasının silik mavisi. "dolgun": aynı doku koyu
* tonda (0,64–1) — rakam gibi okunması gereken şekiller için. */
ton?: "doku" | "dolgun";
/** Isı fırçasının yarıçapı (viewBox birimi; hücre 10) */
yaricap?: number;
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef, { radius: yaricap });
const cols = Math.max(...izgara.map((satir) => satir.length));
const pixels = useMemo(() => {
const opaklik = (x: number, y: number) =>
ton === "dolgun" ? 0.4 + opacityFor(x, y) * 2 : opacityFor(x, y);
const sonuc: ReactNode[] = [];
let vurgu = 0;
izgara.forEach((satir, y) => {
for (let x = 0; x < satir.length; x++) {
const c = satir[x];
if (c === "o") {
sonuc.push(
pixelRect(x, y, {
key: `${x}-${y}`,
accent: true,
accentDelay: (vurgu++ % 8) * 0.45,
}),
);
} else if (c === "#" || c === "-") {
const op =
Math.round(opaklik(x, y) * (c === "-" ? 0.5 : 1) * 100) / 100;
sonuc.push(
<rect
key={`${x}-${y}`}
x={x * CELL + INSET}
y={y * CELL + INSET}
width={DOT}
height={DOT}
rx={2}
data-px=""
data-cx={x * CELL + CELL / 2}
data-cy={y * CELL + CELL / 2}
data-op={op}
fill="currentColor"
fillOpacity={op}
/>,
);
}
}
});
return sonuc;
}, [izgara, ton]);
return (
<svg
ref={svgRef}
viewBox={`0 0 ${cols * CELL} ${izgara.length * CELL}`}
aria-hidden="true"
focusable="false"
className={cn("h-auto w-full text-primary", className)}
>
{pixels}
</svg>
);
}
// 5×7 dot-matrix rakamlar. Yeni bir rakam gerekirse ızgarası buraya eklenir.
const RAKAMLAR = {
1: ["..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."],
2: [".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"],
3: [".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."],
} as const;
/** Hero'nun piksel diliyle yazılmış iri rakam (ana sayfa adım numaraları). */
export function PixelRakam({
rakam,
className,
}: {
rakam: keyof typeof RAKAMLAR;
className?: string;
}) {
return (
<PixelSahne
izgara={RAKAMLAR[rakam]}
ton="dolgun"
yaricap={22}
className={className}
/>
);
}
/** 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-card px-3.5 py-1.5 text-xs font-semibold text-primary",
className,
)}
>
<PixelMark />
{children}
</span>
);
}