perf: harita/tablo re-render'ları + content-visibility (Faz 6)

- tercih-haritasi: viewBox animasyonu transient — kare başına setState
  (380ms × ~23 render × 81 path) yerine svg.setAttribute; il path ref
  callback'leri key başına stabil; il özetleri/şerit/pin grupları useMemo;
  reduce×3+baskinRisk tek geçiş; çift lookup ve çift uniLogoYoluFromAd kalktı
- liste-store: O(1) idSet + useListedeMi/useListeDoluMu primitive selector'ları;
  program-ekle-butonu artık tüm listeye abone değil — 50 satırlık tabloda
  mutasyon başına 50 yerine yalnız değişen buton render olur
- bolum-program-listesi: decorate-sort-undecorate yerine stable toSorted;
  sıralama/sayfalama startTransition'da
- il-secim-haritasi: normalizeIlAdi modül seviyesinde precompute (render
  başına ~1000 string normalizasyonu vardı); Map/Set/sort useMemo
- kt-cv-satir utility: uzun tablo satırlarında content-visibility:auto +
  contain-intrinsic-size (program/bolum/universite/rapor tabloları)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-08 14:23:05 +03:00
parent 668568b5b5
commit f3d8bf9b94
9 changed files with 237 additions and 110 deletions

View File

@@ -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 (
<TableRow key={p.id}>
<TableRow key={p.id} className="kt-cv-satir">
<TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5">
{p.universiteSlug ? (

View File

@@ -66,7 +66,7 @@ export function UniversiteProgramTablosu({
{programlar.map((p) => {
const taban = programEtkinSira(p);
return (
<TableRow key={p.id}>
<TableRow key={p.id} className="kt-cv-satir">
<TableCell className="max-w-0 w-full">
{p.bolumSlug ? (
<Link

View File

@@ -5,9 +5,9 @@ import { toast } from "sonner";
import type { Program } from "@/types/yokatlas";
import { olay } from "@/lib/analitik";
import {
listedeMi,
MANUEL_LISTE_MAX,
useManuelListe,
useListeDoluMu,
useListedeMi,
type ManuelKaynak,
} from "../liste-store";
import { programEkleIste } from "../hooks/use-tercih-profili";
@@ -38,9 +38,11 @@ export function ProgramEkleButonu({
adayTur?: string;
className?: string;
}) {
const liste = useManuelListe();
const listede = liste.some((t) => 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();

View File

@@ -254,7 +254,7 @@ function ProgramSatiri({
const devlet = p.unitur === "DEVLET";
return (
<TableRow>
<TableRow className="kt-cv-satir">
<TableCell className="pl-4 pr-0">
{risk ? (
<span

View File

@@ -57,6 +57,12 @@ const TAM_GORUNUM = {
type ViewBox = typeof TAM_GORUNUM;
// normalizeIlAdi il başına modül yükünde bir kez — render başına 81 çağrı yerine
const NORMAL_SEHIRLER = cities.map((city) => ({
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<string, SVGPathElement>());
// 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<string, SVGPathElement>();
const callbacks = new Map<string, (el: SVGPathElement | null) => 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<SVGSVGElement | null>(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<ViewBox>(TAM_GORUNUM);
const vbRef = useRef<ViewBox>(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<string, HaritaPin[]>();
@@ -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 (
<div>
<div className="relative">
<svg
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
ref={svgMount}
className="h-auto w-full"
role="img"
aria-label="Tercih listesinin Türkiye haritasındaki dağılımı"
>
{/* İ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 (
<path
key={city.id}
ref={(el) => {
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({
>
<title>
{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}
</title>
</path>
@@ -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 (
<g
key={il}
className="cursor-pointer"
onClick={() => onSeciliIlDegisti(il)}
role="button"
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
? ilOzetleri.map(({ il, adet, cx, cy, renk }) => (
<g
key={il}
className="cursor-pointer"
onClick={() => onSeciliIlDegisti(il)}
role="button"
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
>
<circle
cx={cx}
cy={cy}
r={11}
fill={renk}
stroke="white"
strokeWidth={2}
/>
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fontWeight={700}
fill="white"
className="pointer-events-none select-none"
>
<circle
cx={cx}
cy={cy}
r={11}
fill={renk}
stroke="white"
strokeWidth={2}
/>
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fontWeight={700}
fill="white"
className="pointer-events-none select-none"
>
{adet}
</text>
</g>
);
})
{adet}
</text>
</g>
))
: null}
{/* İl seviyesi: üniversite pinleri */}
@@ -426,12 +495,9 @@ export function TercihHaritasi({
{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin
</p>
<ul className="flex gap-2 overflow-x-auto pb-1">
{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 (
<li key={sira} className="shrink-0">
<button
type="button"
@@ -451,9 +517,9 @@ export function TercihHaritasi({
</span>
) : (
<span className="inline-flex min-w-0 items-center gap-1.5">
{uniLogoYoluFromAd(pin.universite) ? (
{logo ? (
<Image
src={uniLogoYoluFromAd(pin.universite)!}
src={logo}
alt=""
width={16}
height={16}
@@ -468,7 +534,8 @@ export function TercihHaritasi({
)}
</button>
</li>
))}
);
})}
</ul>
</div>
) : null}

View File

@@ -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<string> | null = null;
let idSetKaynak: ManuelTercih[] | null = null;
function idSetAl(): Set<string> {
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. */

View File

@@ -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"
>
<TableCell className="pl-4 pr-0">
<span className="flex size-7 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">

View File

@@ -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 (
<div>
@@ -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;