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>
246 lines
9.0 KiB
TypeScript
246 lines
9.0 KiB
TypeScript
"use client";
|
||
|
||
// Program duvarı (DESIGN.md §4.29): "24 tercih, binlerce ihtimal" cümlesinin
|
||
// resmi. Küçük bir pencerede piksel kareler satır satır akar — her kare bir
|
||
// program; aralarında tam 24 turuncu kare yerinde durur: senin listen.
|
||
// İmleç yaklaştığı satırları hızlandırır. Veri taşıyan pikseldir (§1.2);
|
||
// anlamı altındaki cümle söyler, renk tek başına değil (§2.2).
|
||
//
|
||
// Esin: React Bits "Squares Terminal" (yanıp sönen kare ızgarası, imlece
|
||
// tepki veren satırlar). Kod bize ait: koyu CRT ekranı, parlama ve tüp eğimi
|
||
// alınmadı; açık zemin, hero'nun ızgara ölçüleri (hücre 10, nokta 7.4, köşe 2)
|
||
// ve doku opaklıkları kullanılır. Tek <canvas>, yalnız ekrandayken ~30 fps;
|
||
// hareketi azalt'ta durağan tek kare.
|
||
|
||
import { useEffect, useRef } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
const HUCRE = 10;
|
||
const NOKTA = 7.4;
|
||
const BOSLUK = (HUCRE - NOKTA) / 2;
|
||
// Hero ile aynı doku paleti
|
||
const OPAKLIKLAR = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
|
||
const TERCIH = 24;
|
||
/** İmlecin satırları etkilediği dikey uzaklık (px) ve en çok ek hız (hücre/sn). */
|
||
const ETKI = 46;
|
||
const EK_HIZ = 9;
|
||
|
||
// Deterministik sözde-rastgele: sunucuda yok ama her ziyarette aynı desen
|
||
const karma = (x: number, y: number) => {
|
||
const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
|
||
return n - Math.floor(n);
|
||
};
|
||
|
||
export function ProgramDuvari({
|
||
etiket,
|
||
className,
|
||
}: {
|
||
/** Pencere çubuğundaki kısa yazı, ör. "21.000+ program". */
|
||
etiket: string;
|
||
className?: string;
|
||
}) {
|
||
const kapRef = useRef<HTMLDivElement>(null);
|
||
const tuvalRef = useRef<HTMLCanvasElement>(null);
|
||
|
||
useEffect(() => {
|
||
const kap = kapRef.current;
|
||
const tuval = tuvalRef.current;
|
||
const ctx = tuval?.getContext("2d");
|
||
if (!kap || !tuval || !ctx) return;
|
||
|
||
const azalt = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
let en = 0;
|
||
let boy = 0;
|
||
let sutun = 0;
|
||
let satir = 0;
|
||
let mavi = "";
|
||
let turuncu = "";
|
||
/** Satır başına kayma (hücre) ve imleç kaynaklı ek hız. */
|
||
let kayma: number[] = [];
|
||
let ekHiz: number[] = [];
|
||
let tercihler: { c: number; r: number }[] = [];
|
||
let imlecY: number | null = null;
|
||
let gorunur = false;
|
||
let kare = 0;
|
||
let onceki = 0;
|
||
let basladi = 0;
|
||
|
||
const renkleriOku = () => {
|
||
// Tema değişince palet token'ları değişir (§2.3); renk CSS'ten okunur
|
||
mavi = getComputedStyle(tuval).color;
|
||
turuncu =
|
||
getComputedStyle(document.documentElement).getPropertyValue("--color-orange-500").trim() ||
|
||
"#f97316";
|
||
};
|
||
|
||
const olc = () => {
|
||
const kutu = kap.getBoundingClientRect();
|
||
const oran = Math.min(window.devicePixelRatio || 1, 2);
|
||
en = Math.round(kutu.width);
|
||
boy = Math.round(kutu.height);
|
||
tuval.width = en * oran;
|
||
tuval.height = boy * oran;
|
||
ctx.setTransform(oran, 0, 0, oran, 0, 0);
|
||
sutun = Math.ceil(en / HUCRE) + 1;
|
||
satir = Math.floor(boy / HUCRE);
|
||
kayma = Array.from({ length: satir }, (_, r) => kayma[r] ?? karma(r, 3) * 40);
|
||
ekHiz = Array.from({ length: satir }, () => 0);
|
||
// 24 tercih: kenarlardan bir hücre içeride, birbirine değmeyen sabit kareler
|
||
tercihler = [];
|
||
const dolu = new Set<string>();
|
||
for (let i = 0; tercihler.length < TERCIH && i < 4000; i++) {
|
||
const c = 1 + Math.floor(karma(i, 17) * Math.max(1, sutun - 3));
|
||
const r = 1 + Math.floor(karma(i, 29) * Math.max(1, satir - 2));
|
||
const yakin = [-1, 0, 1].some((dx) =>
|
||
[-1, 0, 1].some((dy) => dolu.has(`${c + dx}:${r + dy}`)),
|
||
);
|
||
if (yakin) continue;
|
||
dolu.add(`${c}:${r}`);
|
||
tercihler.push({ c, r });
|
||
}
|
||
};
|
||
|
||
const kareCiz = (x: number, y: number) => {
|
||
ctx.beginPath();
|
||
if (ctx.roundRect) ctx.roundRect(x, y, NOKTA, NOKTA, 2);
|
||
else ctx.rect(x, y, NOKTA, NOKTA);
|
||
ctx.fill();
|
||
};
|
||
|
||
const ciz = (t: number) => {
|
||
ctx.clearRect(0, 0, en, boy);
|
||
const ustBosluk = (boy - satir * HUCRE) / 2;
|
||
ctx.fillStyle = mavi;
|
||
for (let r = 0; r < satir; r++) {
|
||
const tam = Math.floor(kayma[r]);
|
||
const kesir = kayma[r] - tam;
|
||
// Tek satırlar sola, çift satırlar sağa akar
|
||
const yon = r % 2 === 0 ? 1 : -1;
|
||
for (let c = -1; c < sutun; c++) {
|
||
const kimlik = c - yon * tam;
|
||
const h = karma(kimlik, r);
|
||
// Karelerin üçte biri boş: duvar değil doku
|
||
if (h < 0.34) continue;
|
||
const taban = OPAKLIKLAR[Math.abs(kimlik * 7 + r * 11) % OPAKLIKLAR.length];
|
||
// Yavaş nefes: her kare kendi fazında hafifçe parlar ve söner
|
||
const nefes = 0.6 + 0.4 * Math.sin(t * 0.9 + h * 6.283);
|
||
ctx.globalAlpha = taban * nefes;
|
||
kareCiz((c + yon * kesir) * HUCRE + BOSLUK, ustBosluk + r * HUCRE + BOSLUK);
|
||
}
|
||
}
|
||
// Tercihler: sırayla belirir (110 ms arayla), sonra yerinde kalır
|
||
ctx.fillStyle = turuncu;
|
||
const gecen = azalt ? Infinity : t - basladi;
|
||
tercihler.forEach(({ c, r }, i) => {
|
||
const yas = gecen - 0.4 - i * 0.11;
|
||
if (yas <= 0) return;
|
||
const giris = Math.min(1, yas / 0.3);
|
||
const x = c * HUCRE + BOSLUK;
|
||
const y = ustBosluk + r * HUCRE + BOSLUK;
|
||
// Altındaki akan kareyi sil: turuncu kare tek başına okunur
|
||
ctx.globalAlpha = 1;
|
||
ctx.clearRect(c * HUCRE, ustBosluk + r * HUCRE, HUCRE, HUCRE);
|
||
ctx.globalAlpha = 0.9 * giris;
|
||
kareCiz(x, y);
|
||
});
|
||
ctx.globalAlpha = 1;
|
||
};
|
||
|
||
const adim = (simdi: number) => {
|
||
kare = requestAnimationFrame(adim);
|
||
// ~30 fps yeter: kareler yavaş akar
|
||
if (simdi - onceki < 32) return;
|
||
const dt = Math.min(0.1, (simdi - onceki) / 1000);
|
||
onceki = simdi;
|
||
const ustBosluk = (boy - satir * HUCRE) / 2;
|
||
for (let r = 0; r < satir; r++) {
|
||
const merkez = ustBosluk + r * HUCRE + HUCRE / 2;
|
||
const yakinlik =
|
||
imlecY == null ? 0 : Math.max(0, 1 - Math.abs(merkez - imlecY) / ETKI);
|
||
// Ek hız hedefe yumuşak yaklaşır; imleç çekilince yavaşça söner
|
||
ekHiz[r] += (yakinlik * yakinlik * EK_HIZ - ekHiz[r]) * Math.min(1, dt * 5);
|
||
kayma[r] += (0.5 + karma(r, 7) * 1.3 + ekHiz[r]) * dt;
|
||
}
|
||
ciz(simdi / 1000);
|
||
};
|
||
|
||
const baslat = () => {
|
||
if (kare || azalt || !gorunur) return;
|
||
onceki = performance.now();
|
||
if (!basladi) basladi = onceki / 1000;
|
||
kare = requestAnimationFrame(adim);
|
||
};
|
||
const durdur = () => {
|
||
cancelAnimationFrame(kare);
|
||
kare = 0;
|
||
};
|
||
|
||
renkleriOku();
|
||
olc();
|
||
ciz(0);
|
||
|
||
const boyut = new ResizeObserver(() => {
|
||
olc();
|
||
ciz(performance.now() / 1000);
|
||
});
|
||
boyut.observe(kap);
|
||
const gorunum = new IntersectionObserver(([kayit]) => {
|
||
gorunur = kayit.isIntersecting;
|
||
if (gorunur) baslat();
|
||
else durdur();
|
||
});
|
||
gorunum.observe(kap);
|
||
const tema = new MutationObserver(() => {
|
||
renkleriOku();
|
||
ciz(performance.now() / 1000);
|
||
});
|
||
tema.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||
|
||
const imlec = (e: PointerEvent) => {
|
||
if (e.pointerType !== "mouse") return;
|
||
const kutu = kap.getBoundingClientRect();
|
||
const icinde =
|
||
e.clientX >= kutu.left &&
|
||
e.clientX <= kutu.right &&
|
||
e.clientY >= kutu.top - ETKI &&
|
||
e.clientY <= kutu.bottom + ETKI;
|
||
imlecY = icinde ? e.clientY - kutu.top : null;
|
||
};
|
||
window.addEventListener("pointermove", imlec, { passive: true });
|
||
|
||
return () => {
|
||
durdur();
|
||
boyut.disconnect();
|
||
gorunum.disconnect();
|
||
tema.disconnect();
|
||
window.removeEventListener("pointermove", imlec);
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<figure className={cn("mx-auto w-full max-w-3xl", className)}>
|
||
{/* Mini pencere: aramadaki tarayıcı penceresiyle aynı dil (§4.20) */}
|
||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm">
|
||
<div className="flex h-9 items-center gap-1.5 border-b border-slate-100 bg-slate-50 px-3.5">
|
||
<span className="size-2 rounded-full bg-slate-300" aria-hidden />
|
||
<span className="size-2 rounded-full bg-slate-300 opacity-65" aria-hidden />
|
||
<span className="size-2 rounded-full bg-slate-300 opacity-35" aria-hidden />
|
||
<span className="ml-2 text-xs font-medium text-slate-500">{etiket}</span>
|
||
</div>
|
||
<div ref={kapRef} className="h-36 sm:h-44">
|
||
<canvas
|
||
ref={tuvalRef}
|
||
role="img"
|
||
aria-label="Akan piksel kareler: her kare bir program. Aralarında 24 turuncu kare senin tercih listen."
|
||
className="block size-full text-primary"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<figcaption className="mt-3 flex items-center justify-center gap-2 text-center text-sm text-slate-500">
|
||
<span className="size-2.5 shrink-0 rounded-[3px] bg-orange-500" aria-hidden />
|
||
Her kare bir program; turuncular senin 24 tercihin.
|
||
</figcaption>
|
||
</figure>
|
||
);
|
||
}
|