perf: deterministik dekor memo'ları ve küçük render süpürmeleri (Faz 7)
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>
This commit is contained in:
bilalgursen
2026-08-08 14:29:14 +03:00
parent f3d8bf9b94
commit 78d7c51a7a
7 changed files with 129 additions and 86 deletions

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, type ReactNode } from "react"; import { useMemo, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePixelHeat } from "@/components/use-pixel-heat"; import { usePixelHeat } from "@/components/use-pixel-heat";
@@ -88,35 +88,41 @@ export function PixelField({
const COLS = 42; const COLS = 42;
const ROWS = 12; const ROWS = 12;
const cx = (COLS - 1) / 2;
const cy = (ROWS - 1) / 2;
const maxD = Math.sqrt(cx * cx + cy * cy);
const pixels: ReactNode[] = []; // Çıktı seed'e göre deterministik; 504 iterasyon + ~150 element üretimini
let accentIdx = 0; // 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 const sonuc: ReactNode[] = [];
// (genişliğin %2278 bandı) tüm yükseklik boyunca tamamen boş kalır let accentIdx = 0;
const BAND_MIN = COLS * 0.22;
const BAND_MAX = COLS * 0.78;
for (let y = 0; y < ROWS; y++) { // Hero serpintisiyle aynı kural: içeriğin oturduğu orta kolon
for (let x = 0; x < COLS; x++) { // (genişliğin %2278 bandı) tüm yükseklik boyunca tamamen boş kalır
if (x > BAND_MIN && x < BAND_MAX) continue; const BAND_MIN = COLS * 0.22;
// Kenarlara doğru artan doluluk const BAND_MAX = COLS * 0.78;
const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2) / maxD;
const density = 0.1 + 0.42 * d * d; for (let y = 0; y < ROWS; y++) {
if (hash(x, y, seed) > density) continue; for (let x = 0; x < COLS; x++) {
const accent = hash(x, y, seed + 99) > 0.96; if (x > BAND_MIN && x < BAND_MAX) continue;
pixels.push( // Kenarlara doğru artan doluluk
pixelRect(x, y, { const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2) / maxD;
key: `${x}-${y}`, const density = 0.1 + 0.42 * d * d;
accent, if (hash(x, y, seed) > density) continue;
accentDelay: accent ? (accentIdx++ % 8) * 0.45 : 0, 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 ( return (
<svg <svg
@@ -149,28 +155,32 @@ export function PixelDivider({
const COLS = 12; const COLS = 12;
const ROWS = 2; const ROWS = 2;
const cx = (COLS - 1) / 2;
const pixels: ReactNode[] = []; // Deterministik çıktı — render başına yeniden üretme (PixelField ile aynı)
let accentPlaced = false; const pixels = useMemo(() => {
const cx = (COLS - 1) / 2;
const sonuc: ReactNode[] = [];
let accentPlaced = false;
for (let y = 0; y < ROWS; y++) { for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) { for (let x = 0; x < COLS; x++) {
// Az sayıda, iri piksel: ortada yoğun, kenara doğru seyrelen küçük küme // Az sayıda, iri piksel: ortada yoğun, kenara doğru seyrelen küçük küme
const dx = Math.abs(x - cx) / cx; const dx = Math.abs(x - cx) / cx;
const rowFalloff = y === 0 ? 0.9 : 0.35; const rowFalloff = y === 0 ? 0.9 : 0.35;
const density = (1 - dx) ** 1.1 * rowFalloff; const density = (1 - dx) ** 1.1 * rowFalloff;
if (hash(x, y, seed) > density) continue; if (hash(x, y, seed) > density) continue;
const accent = !accentPlaced && hash(x, y, seed + 99) > 0.8; const accent = !accentPlaced && hash(x, y, seed + 99) > 0.8;
if (accent) accentPlaced = true; if (accent) accentPlaced = true;
pixels.push( sonuc.push(
pixelRect(x, y, { pixelRect(x, y, {
key: `${x}-${y}`, key: `${x}-${y}`,
accent, accent,
}), }),
); );
}
} }
} return sonuc;
}, [seed]);
return ( return (
<svg <svg

View File

@@ -26,22 +26,32 @@ export function programEtkinSira(program: SiraSerisi): number | null {
/** 20212025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */ /** 20212025 taban sıralamasını sonuç listesindeki kompakt çizgiyle gösterir. */
export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) { export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
const yillar = [ // Satır başına render edilen bileşen: filter/map/min/max/map/map'in 6 ayrı
// geçişi tek döngüde toplanır (js-combine-iterations, js-min-max-loop)
const yillar: [number, number][] = [];
let min = Infinity;
let max = -Infinity;
const ozetParcalari: string[] = [];
for (const [yil, sira] of [
[2021, program.sira2021], [2021, program.sira2021],
[2022, program.sira2022], [2022, program.sira2022],
[2023, program.sira2023], [2023, program.sira2023],
[2024, program.sira2024], [2024, program.sira2024],
[2025, program.sira2025], [2025, program.sira2025],
].filter((yil): yil is [number, number] => 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 <span className="text-slate-400"></span>; if (yillar.length < 2) return <span className="text-slate-400"></span>;
const siralar = yillar.map(([, sira]) => sira);
const min = Math.min(...siralar);
const max = Math.max(...siralar);
const genislik = 56; const genislik = 56;
const yukseklik = 18; const yukseklik = 18;
const bosluk = 2; 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 noktalar = yillar.map(([yil, sira], index) => {
const x = const x =
bosluk + bosluk +
@@ -54,9 +64,7 @@ export function ProgramSiraGecmisi({ program }: { program: SiraSerisi }) {
return { x, y, yil, sira }; return { x, y, yil, sira };
}); });
const son = noktalar[noktalar.length - 1]; const son = noktalar[noktalar.length - 1];
const ozet = yillar const ozet = ozetParcalari.join("\n");
.map(([yil, sira]) => `${yil}: ~${sira.toLocaleString("tr-TR")}.`)
.join("\n");
return ( return (
<svg <svg

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { startTransition, useEffect, useState } from "react";
import NumberFlow, { NumberFlowGroup } from "@number-flow/react"; import NumberFlow, { NumberFlowGroup } from "@number-flow/react";
import { CalendarDays, Clock3 } from "lucide-react"; import { CalendarDays, Clock3 } from "lucide-react";
@@ -47,7 +47,9 @@ export function SiteTopBanner() {
const [geriSayim, setGeriSayim] = useState<GeriSayim | null>(null); const [geriSayim, setGeriSayim] = useState<GeriSayim | null>(null);
useEffect(() => { 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(); guncelle();
const zamanlayici = window.setInterval(guncelle, 1_000); const zamanlayici = window.setInterval(guncelle, 1_000);

View File

@@ -81,7 +81,6 @@ export function TypingAnimation({
Component Component
] as TypingAnimationMotionComponent ] as TypingAnimationMotionComponent
const [displayedText, setDisplayedText] = useState<string>("")
const [currentWordIndex, setCurrentWordIndex] = useState(0) const [currentWordIndex, setCurrentWordIndex] = useState(0)
const [currentCharIndex, setCurrentCharIndex] = useState(0) const [currentCharIndex, setCurrentCharIndex] = useState(0)
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing") const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
@@ -109,21 +108,32 @@ export function TypingAnimation({
[words, children] [words, children]
) )
// Kaynak metin değişince animasyon baştan başlar — bilinçli reset // Kaynak metin değişince animasyon baştan başlar — önceki-prop deseniyle
useEffect(() => { // render sırasında (effect'te setState çift render yaratıyordu)
// eslint-disable-next-line react-hooks/set-state-in-effect const [oncekiKaynak, setOncekiKaynak] = useState(animationSourceKey)
setDisplayedText("") if (animationSourceKey !== oncekiKaynak) {
setOncekiKaynak(animationSourceKey)
setCurrentWordIndex(0) setCurrentWordIndex(0)
setCurrentCharIndex(0) setCurrentCharIndex(0)
setPhase("typing") 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(() => { useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null let timeout: ReturnType<typeof setTimeout> | null = null
if (!azMotion && shouldStart && wordsToAnimate.length > 0) { if (!azMotion && shouldStart && wordsToAnimate.length > 0) {
const timeoutDelay = const timeoutDelay =
delay > 0 && displayedText === "" delay > 0 && currentCharIndex === 0
? delay ? delay
: phase === "typing" : phase === "typing"
? typingSpeed ? typingSpeed
@@ -138,9 +148,6 @@ export function TypingAnimation({
switch (phase) { switch (phase) {
case "typing": case "typing":
if (currentCharIndex < graphemes.length) { if (currentCharIndex < graphemes.length) {
setDisplayedText(
graphemes.slice(0, currentCharIndex + 1).join("")
)
setCurrentCharIndex(currentCharIndex + 1) setCurrentCharIndex(currentCharIndex + 1)
} else { } else {
if (hasMultipleWords || loop) { if (hasMultipleWords || loop) {
@@ -159,9 +166,6 @@ export function TypingAnimation({
case "deleting": case "deleting":
if (currentCharIndex > 0) { if (currentCharIndex > 0) {
setDisplayedText(
graphemes.slice(0, currentCharIndex - 1).join("")
)
setCurrentCharIndex(currentCharIndex - 1) setCurrentCharIndex(currentCharIndex - 1)
} else { } else {
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
@@ -184,7 +188,6 @@ export function TypingAnimation({
phase, phase,
currentCharIndex, currentCharIndex,
currentWordIndex, currentWordIndex,
displayedText,
wordsToAnimate, wordsToAnimate,
hasMultipleWords, hasMultipleWords,
loop, loop,

View File

@@ -5,7 +5,7 @@
// sayfalanır ("daha fazla göster" /api/programlar'dan akıtır). Her satırın "+" // 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). // 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 Link from "next/link";
import { import {
ChevronDown, 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<DilimKey, ProgramListeSatiri[]>;
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) { async function dahaFazlaYukle(dilim: DilimKey, offset: number) {
setYuklenen(dilim); setYuklenen(dilim);
try { try {
@@ -174,7 +181,7 @@ export function ProgramTablosu({
</TabsList> </TabsList>
{DILIMLER.map((d) => { {DILIMLER.map((d) => {
const satirlar = [...sonuclar[d.key], ...ekstra[d.key]]; const satirlar = tumSatirlar[d.key];
const toplam = sonuclar.toplam[d.key]; const toplam = sonuclar.toplam[d.key];
return ( return (
<TabsContent key={d.key} value={d.key}> <TabsContent key={d.key} value={d.key}>

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { memo } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { UniLogo } from "@/components/uni-logo"; import { UniLogo } from "@/components/uni-logo";
@@ -29,6 +30,7 @@ import {
sonucaGitIste, sonucaGitIste,
useTercihProfili, useTercihProfili,
} from "../hooks/use-tercih-profili"; } from "../hooks/use-tercih-profili";
import type { TercihProfili } from "@/features/sihirbaz/sihirbaz-sabitler";
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = { const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" }, guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },
@@ -176,7 +178,7 @@ export function SecimlerimPaneli() {
<ol className="flex flex-col gap-3"> <ol className="flex flex-col gap-3">
{liste.map((t, i) => ( {liste.map((t, i) => (
<SecimSatiri key={t.id} tercih={t} sira={i + 1} /> <SecimSatiri key={t.id} tercih={t} sira={i + 1} profil={profil} />
))} ))}
</ol> </ol>
</section> </section>
@@ -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, tercih,
sira, sira,
profil,
}: { }: {
tercih: ManuelTercih; tercih: ManuelTercih;
sira: number; sira: number;
profil: TercihProfili | null;
}) { }) {
const profil = useTercihProfili();
const degerlendirme = profil const degerlendirme = profil
? tercihDegerlendir( ? tercihDegerlendir(
{ {
@@ -302,4 +307,4 @@ function SecimSatiri({
</div> </div>
</li> </li>
); );
} });

View File

@@ -8,7 +8,7 @@
// açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin // açık; Yapay Zeka katmanı kilitliBaslangic sonrasında ücretlidir (metin
// sunucuda zaten maskelidir). // sunucuda zaten maskelidir).
import { Fragment, useEffect, useRef, useState } from "react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react"; import { ChevronDown, Lock, MapPin, Rocket, Scale, ShieldCheck } from "lucide-react";
import { UniLogo } from "@/components/uni-logo"; import { UniLogo } from "@/components/uni-logo";
@@ -121,14 +121,20 @@ export function RaporListesi({
/** Detaydaki "Haritada gör" — il DB biçiminde */ /** Detaydaki "Haritada gör" — il DB biçiminde */
onHaritadaGor?: (il: string | null, sira: number) => void; onHaritadaGor?: (il: string | null, sira: number) => void;
}) { }) {
const [acik, setAcik] = useState<Set<number>>(new Set()); // Lazy init: boş Set her render'da boşuna allocate edilmesin
const [acik, setAcik] = useState<Set<number>>(() => new Set());
const satirRefs = useRef(new Map<number, HTMLTableRowElement>()); const satirRefs = useRef(new Map<number, HTMLTableRowElement>());
// Haritadan gelen seçim: satırı ve görünür alana kaydır // Haritadan gelen seçim: satırını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(() => { useEffect(() => {
if (acikSira == null) return; if (acikSira == null) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setAcik((mevcut) => new Set(mevcut).add(acikSira));
satirRefs.current satirRefs.current
.get(acikSira) .get(acikSira)
?.scrollIntoView({ behavior: "smooth", block: "center" }); ?.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -145,12 +151,14 @@ export function RaporListesi({
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length; const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
const iller = new Set( const tekIl = useMemo(() => {
rapor.tercihler const iller = new Set<string>();
.map((t) => rapor.programlar[t.programId]?.il) for (const t of rapor.tercihler) {
.filter((il): il is string => Boolean(il)), const il = rapor.programlar[t.programId]?.il;
); if (il) iller.add(il);
const tekIl = iller.size === 1 ? [...iller][0] : null; }
return iller.size === 1 ? [...iller][0] : null;
}, [rapor]);
const satir = (t: RaporSonuc["tercihler"][number], index: number) => { const satir = (t: RaporSonuc["tercihler"][number], index: number) => {
const p = rapor.programlar[t.programId]; const p = rapor.programlar[t.programId];