diff --git a/src/components/pixel-decor.tsx b/src/components/pixel-decor.tsx index 26cd9e9..8bf7c34 100644 --- a/src/components/pixel-decor.tsx +++ b/src/components/pixel-decor.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, type ReactNode } from "react"; +import { useMemo, useRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import { usePixelHeat } from "@/components/use-pixel-heat"; @@ -88,35 +88,41 @@ export function PixelField({ const COLS = 42; const ROWS = 12; - const cx = (COLS - 1) / 2; - const cy = (ROWS - 1) / 2; - const maxD = Math.sqrt(cx * cx + cy * cy); - const pixels: ReactNode[] = []; - let accentIdx = 0; + // Çı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); - // 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; + const sonuc: ReactNode[] = []; + let accentIdx = 0; - 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; - pixels.push( - pixelRect(x, y, { - key: `${x}-${y}`, - accent, - accentDelay: accent ? (accentIdx++ % 8) * 0.45 : 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 ( { + 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; - pixels.push( - pixelRect(x, y, { - key: `${x}-${y}`, - accent, - }), - ); + 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 ( yil[1] != null); + ] as const) { + if (sira == null) continue; + yillar.push([yil, sira]); + if (sira < min) min = sira; + if (sira > max) max = sira; + ozetParcalari.push(`${yil}: ~${sira.toLocaleString("tr-TR")}.`); + } if (yillar.length < 2) return ; - const siralar = yillar.map(([, sira]) => sira); - const min = Math.min(...siralar); - const max = Math.max(...siralar); const genislik = 56; const yukseklik = 18; const bosluk = 2; + // min/max'a bağımlı olduğu için ikinci kısa döngü const noktalar = yillar.map(([yil, sira], index) => { const x = bosluk + @@ -54,9 +64,7 @@ export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) { return { x, y, yil, sira }; }); const son = noktalar[noktalar.length - 1]; - const ozet = yillar - .map(([yil, sira]) => `${yil}: ~${sira.toLocaleString("tr-TR")}.`) - .join("\n"); + const ozet = ozetParcalari.join("\n"); return ( (null); useEffect(() => { - const guncelle = () => setGeriSayim(geriSayimiHesapla(Date.now())); + // Saniyelik tik acil değil — layout'ta her sayfada monte; urgent şeritten çık + const guncelle = () => + startTransition(() => setGeriSayim(geriSayimiHesapla(Date.now()))); guncelle(); const zamanlayici = window.setInterval(guncelle, 1_000); diff --git a/src/components/ui/typing-animation.tsx b/src/components/ui/typing-animation.tsx index 8641732..6e622f9 100644 --- a/src/components/ui/typing-animation.tsx +++ b/src/components/ui/typing-animation.tsx @@ -81,7 +81,6 @@ export function TypingAnimation({ Component ] as TypingAnimationMotionComponent - const [displayedText, setDisplayedText] = useState("") const [currentWordIndex, setCurrentWordIndex] = useState(0) const [currentCharIndex, setCurrentCharIndex] = useState(0) const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing") @@ -109,21 +108,32 @@ export function TypingAnimation({ [words, children] ) - // Kaynak metin değişince animasyon baştan başlar — bilinçli reset - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setDisplayedText("") + // Kaynak metin değişince animasyon baştan başlar — önceki-prop deseniyle + // render sırasında (effect'te setState çift render yaratıyordu) + const [oncekiKaynak, setOncekiKaynak] = useState(animationSourceKey) + if (animationSourceKey !== oncekiKaynak) { + setOncekiKaynak(animationSourceKey) setCurrentWordIndex(0) setCurrentCharIndex(0) setPhase("typing") - }, [animationSourceKey]) + } + + // Görünen metin state değil türetme: tick başına ekstra state yazımı ve + // grapheme dizisi allocation'ı kalkar (rerender-derived-state-no-effect) + const displayedText = useMemo( + () => + Array.from(wordsToAnimate[currentWordIndex] || "") + .slice(0, currentCharIndex) + .join(""), + [wordsToAnimate, currentWordIndex, currentCharIndex] + ) useEffect(() => { let timeout: ReturnType | null = null if (!azMotion && shouldStart && wordsToAnimate.length > 0) { const timeoutDelay = - delay > 0 && displayedText === "" + delay > 0 && currentCharIndex === 0 ? delay : phase === "typing" ? typingSpeed @@ -138,9 +148,6 @@ export function TypingAnimation({ switch (phase) { case "typing": if (currentCharIndex < graphemes.length) { - setDisplayedText( - graphemes.slice(0, currentCharIndex + 1).join("") - ) setCurrentCharIndex(currentCharIndex + 1) } else { if (hasMultipleWords || loop) { @@ -159,9 +166,6 @@ export function TypingAnimation({ case "deleting": if (currentCharIndex > 0) { - setDisplayedText( - graphemes.slice(0, currentCharIndex - 1).join("") - ) setCurrentCharIndex(currentCharIndex - 1) } else { const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length @@ -184,7 +188,6 @@ export function TypingAnimation({ phase, currentCharIndex, currentWordIndex, - displayedText, wordsToAnimate, hasMultipleWords, loop, diff --git a/src/features/liste/components/program-tablosu.tsx b/src/features/liste/components/program-tablosu.tsx index f8fb7e4..129f87c 100644 --- a/src/features/liste/components/program-tablosu.tsx +++ b/src/features/liste/components/program-tablosu.tsx @@ -5,7 +5,7 @@ // sayfalanır ("daha fazla göster" /api/programlar'dan akıtır). Her satırın "+" // butonu programı manuel 24'lük listeye ekler (bkz. manuel-liste/store). -import { useState } from "react"; +import { useMemo, useState } from "react"; import Link from "next/link"; import { ChevronDown, @@ -119,6 +119,13 @@ export function ProgramTablosu({ } } + // Görünmeyen sekmeler dahil 3 dilimin spread'i her render'da tekrarlanmasın + const tumSatirlar = useMemo(() => { + const r = {} as Record; + for (const d of DILIMLER) r[d.key] = [...sonuclar[d.key], ...ekstra[d.key]]; + return r; + }, [sonuclar, ekstra]); + async function dahaFazlaYukle(dilim: DilimKey, offset: number) { setYuklenen(dilim); try { @@ -174,7 +181,7 @@ export function ProgramTablosu({ {DILIMLER.map((d) => { - const satirlar = [...sonuclar[d.key], ...ekstra[d.key]]; + const satirlar = tumSatirlar[d.key]; const toplam = sonuclar.toplam[d.key]; return ( diff --git a/src/features/liste/components/secimlerim-paneli.tsx b/src/features/liste/components/secimlerim-paneli.tsx index cfa6631..9edc1b7 100644 --- a/src/features/liste/components/secimlerim-paneli.tsx +++ b/src/features/liste/components/secimlerim-paneli.tsx @@ -1,5 +1,6 @@ "use client"; +import { memo } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { UniLogo } from "@/components/uni-logo"; @@ -29,6 +30,7 @@ import { sonucaGitIste, useTercihProfili, } from "../hooks/use-tercih-profili"; +import type { TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler"; const RISK_STIL: Record = { guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" }, @@ -176,7 +178,7 @@ export function SecimlerimPaneli() {
    {liste.map((t, i) => ( - + ))}
@@ -196,14 +198,17 @@ function Istatistik({ etiket, deger }: { etiket: string; deger: number }) { ); } -function SecimSatiri({ +// Profil parent'tan prop olarak gelir (parent zaten abone) + memo: 24 satırın +// her biri ayrı store aboneliği kurup her profil olayında uyanıyordu. +const SecimSatiri = memo(function SecimSatiri({ tercih, sira, + profil, }: { tercih: ManuelTercih; sira: number; + profil: TercihProfili | null; }) { - const profil = useTercihProfili(); const degerlendirme = profil ? tercihDegerlendir( { @@ -302,4 +307,4 @@ function SecimSatiri({ ); -} +}); diff --git a/src/features/rapor/components/rapor-listesi.tsx b/src/features/rapor/components/rapor-listesi.tsx index 75f6f30..52ca845 100644 --- a/src/features/rapor/components/rapor-listesi.tsx +++ b/src/features/rapor/components/rapor-listesi.tsx @@ -8,7 +8,7 @@ // açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin // sunucuda zaten maskelidir). -import { Fragment, useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react"; import { UniLogo } from "@/components/uni-logo"; @@ -121,14 +121,20 @@ export function RaporListesi({ /** Detaydaki "Haritada gör" — il DB biçiminde */ onHaritadaGor?: (il: string | null, sira: number) => void; }) { - const [acik, setAcik] = useState>(new Set()); + // Lazy init: boş Set her render'da boşuna allocate edilmesin + const [acik, setAcik] = useState>(() => new Set()); const satirRefs = useRef(new Map()); - // Haritadan gelen seçim: satırı aç ve görünür alana kaydır + // Haritadan gelen seçim: satırın açılması render sırasındaki önceki-prop + // deseniyle (effect'te setState çift render yaratıyordu); yalnızca DOM işi + // olan scrollIntoView effect'te kalır. + const [oncekiAcikSira, setOncekiAcikSira] = useState(acikSira); + if (acikSira !== oncekiAcikSira) { + setOncekiAcikSira(acikSira); + if (acikSira != null) setAcik((mevcut) => new Set(mevcut).add(acikSira)); + } useEffect(() => { if (acikSira == null) return; - // eslint-disable-next-line react-hooks/set-state-in-effect - setAcik((mevcut) => new Set(mevcut).add(acikSira)); satirRefs.current .get(acikSira) ?.scrollIntoView({ behavior: "smooth", block: "center" }); @@ -145,12 +151,14 @@ export function RaporListesi({ const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length; - const iller = new Set( - rapor.tercihler - .map((t) => rapor.programlar[t.programId]?.il) - .filter((il): il is string => Boolean(il)), - ); - const tekIl = iller.size === 1 ? [...iller][0] : null; + const tekIl = useMemo(() => { + const iller = new Set(); + for (const t of rapor.tercihler) { + const il = rapor.programlar[t.programId]?.il; + if (il) iller.add(il); + } + return iller.size === 1 ? [...iller][0] : null; + }, [rapor]); const satir = (t: RaporSonuc["tercihler"][number], index: number) => { const p = rapor.programlar[t.programId];