diff --git a/src/app/globals.css b/src/app/globals.css index e87a933..7589757 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -194,6 +194,15 @@ } } +/* Uzun tablo satırları: viewport dışı satırların layout/paint'i atlanır + (rendering-content-visibility). contain-intrinsic-size satır yüksekliği + tahminidir — scrollbar zıplamasını önler. Sorun çıkarsa satır bazında + class kaldırılarak geri alınabilir. */ +@utility kt-cv-satir { + content-visibility: auto; + contain-intrinsic-size: auto 57px; +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/src/features/katalog/components/bolum-program-listesi.tsx b/src/features/katalog/components/bolum-program-listesi.tsx index d7896fb..2051159 100644 --- a/src/features/katalog/components/bolum-program-listesi.tsx +++ b/src/features/katalog/components/bolum-program-listesi.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useRef, useState } from "react"; +import { startTransition, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { ChevronDown, ChevronUp } from "lucide-react"; import type { Program } from "@/types/yokatlas"; @@ -104,19 +104,17 @@ export function BolumProgramListesi({ const siraliProgramlar = useMemo(() => { if (!aktifAlan) return programlar; - return programlar - .map((program, index) => ({ program, index })) - .sort((a, b) => { - const aDeger = a.program[aktifAlan]; - const bDeger = b.program[aktifAlan]; - if (aDeger == null && bDeger == null) return a.index - b.index; - if (aDeger == null) return 1; - if (bDeger == null) return -1; - - const fark = aDeger - bDeger; - return fark === 0 ? a.index - b.index : yon === "artan" ? fark : -fark; - }) - .map(({ program }) => program); + // toSorted stable: eşitlikte orijinal sıra zaten korunur, index + // dekorasyonu (N obje allocation + 2 ek geçiş) gereksizdi. + return programlar.toSorted((a, b) => { + const aDeger = a[aktifAlan]; + const bDeger = b[aktifAlan]; + if (aDeger == null && bDeger == null) return 0; + if (aDeger == null) return 1; + if (bDeger == null) return -1; + const fark = aDeger - bDeger; + return yon === "artan" ? fark : -fark; + }); }, [aktifAlan, programlar, yon]); const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa; @@ -129,18 +127,22 @@ export function BolumProgramListesi({ [siraliProgramlar, gosterilenSayfa], ); + // Yüzlerce satırın sort+render'ı tıklamayı bloklamasın; buton geri + // bildirimi CSS'ten geldiği için tüm güncelleme non-urgent kalabilir. function sirala(alan: SiralamaAlani) { - setIstemciSayfa(1); - if (aktifAlan === alan) { - setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan")); - return; - } - setAktifAlan(alan); - setYon("artan"); + startTransition(() => { + setIstemciSayfa(1); + if (aktifAlan === alan) { + setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan")); + return; + } + setAktifAlan(alan); + setYon("artan"); + }); } function istemciSayfaDegistir(n: number) { - setIstemciSayfa(n); + startTransition(() => setIstemciSayfa(n)); listeRef.current?.scrollIntoView({ block: "start" }); } @@ -193,7 +195,7 @@ export function BolumProgramListesi({ const devlet = p.unitur === "DEVLET"; return ( - + {p.universiteSlug ? ( diff --git a/src/features/katalog/components/universite-program-tablosu.tsx b/src/features/katalog/components/universite-program-tablosu.tsx index af39dde..34f35b3 100644 --- a/src/features/katalog/components/universite-program-tablosu.tsx +++ b/src/features/katalog/components/universite-program-tablosu.tsx @@ -66,7 +66,7 @@ export function UniversiteProgramTablosu({ {programlar.map((p) => { const taban = programEtkinSira(p); return ( - + {p.bolumSlug ? ( t.id === program.id) || listedeMi(program.id); - const doldu = liste.length >= MANUEL_LISTE_MAX; + // Primitive snapshot'lı selector'lar: liste mutasyonunda yalnız üyeliği + // değişen buton render olur (eskiden 50 satır × tam liste aboneliği + aynı + // taramanın listedeMi ile ikinci kez yapılması vardı). + const listede = useListedeMi(program.id); + const doldu = useListeDoluMu(); function handleClick(e: React.MouseEvent) { e.preventDefault(); diff --git a/src/features/liste/components/program-tablosu.tsx b/src/features/liste/components/program-tablosu.tsx index 6637628..f8fb7e4 100644 --- a/src/features/liste/components/program-tablosu.tsx +++ b/src/features/liste/components/program-tablosu.tsx @@ -254,7 +254,7 @@ function ProgramSatiri({ const devlet = p.unitur === "DEVLET"; return ( - + {risk ? ( ({ + city, + key: normalizeIlAdi(city.name), +})); + /** "ORTA DOĞU TEKNİK ÜNİVERSİTESİ" → "Orta Doğu Teknik" */ function kisaUniAdi(ad: string): string { const kisa = ad @@ -122,18 +128,54 @@ export function TercihHaritasi({ /** Alt şeritte bir tercihe dokunulunca (liste satırına in) */ onTercihSec?: (sira: number) => void; }) { - const pathRefs = useRef(new Map()); + // 81 il path'inin inline ref callback'i her render'da yeniden üretilmesin + // (React her render'da 81 detach/attach yapıyordu): key başına stabil + // callback bir kez kurulur (il listesi statik). Ref yerine lazy-state + // closure'ı — callback'ler commit anında koşar, render'da ref okunmaz. + const [pathKayit] = useState(() => { + const elemanlar = new Map(); + const callbacks = new Map void>(); + for (const { key } of NORMAL_SEHIRLER) { + callbacks.set(key, (el) => { + if (el) elemanlar.set(key, el); + else elemanlar.delete(key); + }); + } + return { elemanlar, callbacks }; + }); + const svgRef = useRef(null); + // vb yalnızca sf (zoom oran çarpanı) için state; viewBox akışı TRANSIENT — + // kare başına setState 380ms boyunca tüm SVG ağacını (~23 render × 81 path) + // yeniden kuruyordu. Artık animasyon svg.setAttribute ile ref üzerinden + // akar, React yalnızca animasyon başında bir kez render eder. const [vb, setVb] = useState(TAM_GORUNUM); const vbRef = useRef(TAM_GORUNUM); const animRef = useRef(0); + // viewBox JSX'te değil: React'in commit'i animasyon karesini ezmesin. + // İlk değer mount'ta bir kez yazılır (stabil callback → yalnızca mount'ta). + const svgMount = useCallback((el: SVGSVGElement | null) => { + svgRef.current = el; + if (el) { + const v = vbRef.current; + el.setAttribute("viewBox", `${v.x} ${v.y} ${v.w} ${v.h}`); + } + }, []); + const animateTo = useCallback((hedef: ViewBox) => { cancelAnimationFrame(animRef.current); const kaynak = { ...vbRef.current }; const t0 = performance.now(); const SURE = 380; const ease = (t: number) => 1 - Math.pow(1 - t, 3); + let ilkKare = true; const adim = (now: number) => { + if (ilkKare) { + ilkKare = false; + // sf'e bağlı ölçüler tek render'da nihai değere oturur; rAF içinde + // (asenkron) çağrıldığı için effect'te senkron setState sayılmaz. + setVb(hedef); + } const t = Math.min(1, (now - t0) / SURE); const k = ease(t); const sonraki = { @@ -143,7 +185,10 @@ export function TercihHaritasi({ h: kaynak.h + (hedef.h - kaynak.h) * k, }; vbRef.current = sonraki; - setVb(sonraki); + svgRef.current?.setAttribute( + "viewBox", + `${sonraki.x} ${sonraki.y} ${sonraki.w} ${sonraki.h}`, + ); if (t < 1) animRef.current = requestAnimationFrame(adim); }; animRef.current = requestAnimationFrame(adim); @@ -157,7 +202,7 @@ export function TercihHaritasi({ animateTo(TAM_GORUNUM); return; } - const path = pathRefs.current.get(seciliIl); + const path = pathKayit.elemanlar.get(seciliIl); if (!path) return; const b = path.getBBox(); const pad = Math.max(b.width, b.height) * 0.22; @@ -178,7 +223,7 @@ export function TercihHaritasi({ w = yeniW; } animateTo({ x, y, w, h }); - }, [seciliIl, animateTo]); + }, [seciliIl, animateTo, pathKayit.elemanlar]); const ilPinleri = useMemo(() => { const gruplar = new Map(); @@ -194,30 +239,68 @@ export function TercihHaritasi({ // Ekran boyutu sabit kalsın diye SVG içi ölçüler zoom oranıyla çarpılır const sf = vb.w / HARITA_GENISLIK; const zoomda = Boolean(seciliIl); - const seciliPinler = seciliIl ? (ilPinleri.get(seciliIl) ?? []) : []; - const acikPinler = cakismaAc(seciliPinler); + const seciliPinler = useMemo( + () => (seciliIl ? (ilPinleri.get(seciliIl) ?? []) : []), + [ilPinleri, seciliIl], + ); + const acikPinler = useMemo(() => cakismaAc(seciliPinler), [seciliPinler]); + + // Ülke seviyesi toplu pinler: il başına adet/merkez/renk tek geçişte + // (üç ayrı reduce + baskinRisk dördüncü geçişti) ve memo'da. + const ilOzetleri = useMemo( + () => + [...ilPinleri.entries()].map(([il, grup]) => { + let adet = 0; + let cx = 0; + let cy = 0; + for (const p of grup) { + adet += p.tercihSiralari.length; + cx += p.x; + cy += p.y; + } + return { + il, + adet, + cx: cx / grup.length, + cy: cy / grup.length, + renk: RISK_RENK[baskinRisk(grup)], + }; + }), + [ilPinleri], + ); + const ilAdetleri = useMemo( + () => new Map(ilOzetleri.map((o) => [o.il, o.adet])), + [ilOzetleri], + ); + + // Alt şerit: seçili ilin tercihleri sıraya göre (memo'suz flatMap+sort + // her render'da yeniden kuruluyordu) + const seritTercihleri = useMemo( + () => + seciliPinler + .flatMap((p) => p.tercihSiralari.map((sira) => ({ pin: p, sira }))) + .sort((a, b) => a.sira - b.sira), + [seciliPinler], + ); return (
{/* İl zeminleri */} - {cities.map((city) => { - const key = normalizeIlAdi(city.name); - const iceriyor = ilPinleri.has(key); + {NORMAL_SEHIRLER.map(({ city, key }) => { + const ilAdet = ilAdetleri.get(key); + const iceriyor = ilAdet !== undefined; const secili = key === seciliIl; return ( { - if (el) pathRefs.current.set(key, el); - else pathRefs.current.delete(key); - }} + ref={pathKayit.callbacks.get(key)} d={city.path} className={ iceriyor @@ -240,10 +323,7 @@ export function TercihHaritasi({ > {iceriyor - ? `${city.name}: ${(ilPinleri.get(key) ?? []).reduce( - (a, p) => a + p.tercihSiralari.length, - 0, - )} tercih — yakınlaşmak için tıkla` + ? `${city.name}: ${ilAdet} tercih — yakınlaşmak için tıkla` : city.name} @@ -252,47 +332,36 @@ export function TercihHaritasi({ {/* Ülke seviyesi: il başına toplu pin */} {!zoomda - ? [...ilPinleri.entries()].map(([il, iller]) => { - const adet = iller.reduce( - (a, p) => a + p.tercihSiralari.length, - 0, - ); - const cx = - iller.reduce((a, p) => a + p.x, 0) / iller.length; - const cy = - iller.reduce((a, p) => a + p.y, 0) / iller.length; - const renk = RISK_RENK[baskinRisk(iller)]; - return ( - onSeciliIlDegisti(il)} - role="button" - aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`} + ? ilOzetleri.map(({ il, adet, cx, cy, renk }) => ( + onSeciliIlDegisti(il)} + role="button" + aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`} + > + + - - - {adet} - - - ); - }) + {adet} + + + )) : null} {/* İl seviyesi: üniversite pinleri */} @@ -426,12 +495,9 @@ export function TercihHaritasi({ {seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin

    - {seciliPinler - .flatMap((p) => - p.tercihSiralari.map((sira) => ({ pin: p, sira })), - ) - .sort((a, b) => a.sira - b.sira) - .map(({ pin, sira }) => ( + {seritTercihleri.map(({ pin, sira }) => { + const logo = pin.kilitli ? null : uniLogoYoluFromAd(pin.universite); + return (
  • - ))} + ); + })}
) : null} diff --git a/src/features/liste/liste-store.ts b/src/features/liste/liste-store.ts index 3b34f8e..eb36418 100644 --- a/src/features/liste/liste-store.ts +++ b/src/features/liste/liste-store.ts @@ -87,8 +87,42 @@ export function useCekmeceAcik(): boolean { ); } +// Üyelik kontrolü O(1): id Set'i tembel kurulur, `liste` her mutasyonda yeni +// referans aldığı için referans karşılaştırması mükemmel geçersizleme anahtarı. +let idSet: Set | null = null; +let idSetKaynak: ManuelTercih[] | null = null; +function idSetAl(): Set { + if (idSetKaynak !== liste || idSet === null) { + idSet = new Set(liste.map((t) => t.id)); + idSetKaynak = liste; + } + return idSet; +} + export function listedeMi(id: string): boolean { - return liste.some((t) => t.id === id); + return idSetAl().has(id); +} + +/** + * Satır başına üyelik aboneliği (program-ekle-butonu 50+ satırda render + * ediliyor): snapshot primitive boolean olduğu için liste değişse bile yalnız + * üyeliği değişen butonlar yeniden render olur — tüm listeye abone olup her + * mutasyonda 50 satırı uyandırmak yerine. + */ +export function useListedeMi(id: string): boolean { + return useSyncExternalStore( + abone, + () => idSetAl().has(id), + () => false, + ); +} + +export function useListeDoluMu(): boolean { + return useSyncExternalStore( + abone, + () => liste.length >= MANUEL_LISTE_MAX, + () => false, + ); } /** Senkron snapshot — giriş anı merge'inde sunucuya gönderilecek liste. */ diff --git a/src/features/rapor/components/rapor-listesi.tsx b/src/features/rapor/components/rapor-listesi.tsx index dbd5a7e..75f6f30 100644 --- a/src/features/rapor/components/rapor-listesi.tsx +++ b/src/features/rapor/components/rapor-listesi.tsx @@ -172,7 +172,7 @@ export function RaporListesi({ else satirRefs.current.delete(t.sira); }} onClick={() => tiklandi(t.sira)} - className="cursor-pointer" + className="kt-cv-satir cursor-pointer" > diff --git a/src/features/sihirbaz/components/il-secim-haritasi.tsx b/src/features/sihirbaz/components/il-secim-haritasi.tsx index daabe4c..50b1b65 100644 --- a/src/features/sihirbaz/components/il-secim-haritasi.tsx +++ b/src/features/sihirbaz/components/il-secim-haritasi.tsx @@ -4,6 +4,7 @@ // il seçilen Türkiye haritası. Mavi ton ildeki program sayısını, turuncu // kontur seçimi gösterir. KKTC gibi harita dışı yerler çip olarak eklenir. +import { useMemo } from "react"; import { cities } from "turkey-map-react/lib/data"; import { HARITA_GENISLIK, @@ -12,6 +13,14 @@ import { normalizeIlAdi, } from "@/lib/harita"; +// normalizeIlAdi (3 replace + toLocaleUpperCase) modül yükünde İL BAŞINA BİR +// kez koşar; eskiden her render'da sort comparator içinde ~1000 kez +// çağrılıyordu (81 il × ~çift log n karşılaştırma × 2 çağrı). +const NORMAL_SEHIRLER = cities.map((city) => ({ + city, + key: normalizeIlAdi(city.name), +})); + export function IlSecimHaritasi({ iller, secili, @@ -22,9 +31,20 @@ export function IlSecimHaritasi({ secili: string[]; onToggle: (il: string) => void; }) { - const adetler = new Map(iller.map((i) => [i.il, i.adet])); - const maxAdet = Math.max(1, ...iller.map((i) => i.adet)); - const seciliSet = new Set(secili); + const { adetler, maxAdet } = useMemo(() => { + const adetler = new Map(iller.map((i) => [i.il, i.adet])); + let maxAdet = 1; + for (const i of iller) if (i.adet > maxAdet) maxAdet = i.adet; + return { adetler, maxAdet }; + }, [iller]); + const seciliSet = useMemo(() => new Set(secili), [secili]); + const siraliSehirler = useMemo( + () => + [...NORMAL_SEHIRLER].sort( + (a, b) => Number(seciliSet.has(a.key)) - Number(seciliSet.has(b.key)), + ), + [seciliSet], + ); return (
@@ -35,14 +55,7 @@ export function IlSecimHaritasi({ aria-label="Okumak istediğin illeri haritadan seç" > {/* Seçili iller en son çizilir ki turuncu kontur komşularca örtülmesin */} - {[...cities] - .sort( - (a, b) => - Number(seciliSet.has(normalizeIlAdi(a.name))) - - Number(seciliSet.has(normalizeIlAdi(b.name))), - ) - .map((city) => { - const key = normalizeIlAdi(city.name); + {siraliSehirler.map(({ city, key }) => { const adet = adetler.get(key); const seciliMi = seciliSet.has(key); const yogunluk = adet ? adet / maxAdet : 0;