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:
@@ -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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { startTransition, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import type { Program } from "@/types/yokatlas";
|
import type { Program } from "@/types/yokatlas";
|
||||||
@@ -104,19 +104,17 @@ export function BolumProgramListesi({
|
|||||||
const siraliProgramlar = useMemo(() => {
|
const siraliProgramlar = useMemo(() => {
|
||||||
if (!aktifAlan) return programlar;
|
if (!aktifAlan) return programlar;
|
||||||
|
|
||||||
return programlar
|
// toSorted stable: eşitlikte orijinal sıra zaten korunur, index
|
||||||
.map((program, index) => ({ program, index }))
|
// dekorasyonu (N obje allocation + 2 ek geçiş) gereksizdi.
|
||||||
.sort((a, b) => {
|
return programlar.toSorted((a, b) => {
|
||||||
const aDeger = a.program[aktifAlan];
|
const aDeger = a[aktifAlan];
|
||||||
const bDeger = b.program[aktifAlan];
|
const bDeger = b[aktifAlan];
|
||||||
if (aDeger == null && bDeger == null) return a.index - b.index;
|
if (aDeger == null && bDeger == null) return 0;
|
||||||
if (aDeger == null) return 1;
|
if (aDeger == null) return 1;
|
||||||
if (bDeger == null) return -1;
|
if (bDeger == null) return -1;
|
||||||
|
|
||||||
const fark = aDeger - bDeger;
|
const fark = aDeger - bDeger;
|
||||||
return fark === 0 ? a.index - b.index : yon === "artan" ? fark : -fark;
|
return yon === "artan" ? fark : -fark;
|
||||||
})
|
});
|
||||||
.map(({ program }) => program);
|
|
||||||
}, [aktifAlan, programlar, yon]);
|
}, [aktifAlan, programlar, yon]);
|
||||||
|
|
||||||
const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa;
|
const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa;
|
||||||
@@ -129,7 +127,10 @@ export function BolumProgramListesi({
|
|||||||
[siraliProgramlar, gosterilenSayfa],
|
[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) {
|
function sirala(alan: SiralamaAlani) {
|
||||||
|
startTransition(() => {
|
||||||
setIstemciSayfa(1);
|
setIstemciSayfa(1);
|
||||||
if (aktifAlan === alan) {
|
if (aktifAlan === alan) {
|
||||||
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
|
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
|
||||||
@@ -137,10 +138,11 @@ export function BolumProgramListesi({
|
|||||||
}
|
}
|
||||||
setAktifAlan(alan);
|
setAktifAlan(alan);
|
||||||
setYon("artan");
|
setYon("artan");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function istemciSayfaDegistir(n: number) {
|
function istemciSayfaDegistir(n: number) {
|
||||||
setIstemciSayfa(n);
|
startTransition(() => setIstemciSayfa(n));
|
||||||
listeRef.current?.scrollIntoView({ block: "start" });
|
listeRef.current?.scrollIntoView({ block: "start" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ export function BolumProgramListesi({
|
|||||||
const devlet = p.unitur === "DEVLET";
|
const devlet = p.unitur === "DEVLET";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={p.id}>
|
<TableRow key={p.id} className="kt-cv-satir">
|
||||||
<TableCell className="max-w-0 w-full">
|
<TableCell className="max-w-0 w-full">
|
||||||
<span className="flex min-w-0 items-center gap-2.5">
|
<span className="flex min-w-0 items-center gap-2.5">
|
||||||
{p.universiteSlug ? (
|
{p.universiteSlug ? (
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function UniversiteProgramTablosu({
|
|||||||
{programlar.map((p) => {
|
{programlar.map((p) => {
|
||||||
const taban = programEtkinSira(p);
|
const taban = programEtkinSira(p);
|
||||||
return (
|
return (
|
||||||
<TableRow key={p.id}>
|
<TableRow key={p.id} className="kt-cv-satir">
|
||||||
<TableCell className="max-w-0 w-full">
|
<TableCell className="max-w-0 w-full">
|
||||||
{p.bolumSlug ? (
|
{p.bolumSlug ? (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { toast } from "sonner";
|
|||||||
import type { Program } from "@/types/yokatlas";
|
import type { Program } from "@/types/yokatlas";
|
||||||
import { olay } from "@/lib/analitik";
|
import { olay } from "@/lib/analitik";
|
||||||
import {
|
import {
|
||||||
listedeMi,
|
|
||||||
MANUEL_LISTE_MAX,
|
MANUEL_LISTE_MAX,
|
||||||
useManuelListe,
|
useListeDoluMu,
|
||||||
|
useListedeMi,
|
||||||
type ManuelKaynak,
|
type ManuelKaynak,
|
||||||
} from "../liste-store";
|
} from "../liste-store";
|
||||||
import { programEkleIste } from "../hooks/use-tercih-profili";
|
import { programEkleIste } from "../hooks/use-tercih-profili";
|
||||||
@@ -38,9 +38,11 @@ export function ProgramEkleButonu({
|
|||||||
adayTur?: string;
|
adayTur?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const liste = useManuelListe();
|
// Primitive snapshot'lı selector'lar: liste mutasyonunda yalnız üyeliği
|
||||||
const listede = liste.some((t) => t.id === program.id) || listedeMi(program.id);
|
// değişen buton render olur (eskiden 50 satır × tam liste aboneliği + aynı
|
||||||
const doldu = liste.length >= MANUEL_LISTE_MAX;
|
// taramanın listedeMi ile ikinci kez yapılması vardı).
|
||||||
|
const listede = useListedeMi(program.id);
|
||||||
|
const doldu = useListeDoluMu();
|
||||||
|
|
||||||
function handleClick(e: React.MouseEvent) {
|
function handleClick(e: React.MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ function ProgramSatiri({
|
|||||||
const devlet = p.unitur === "DEVLET";
|
const devlet = p.unitur === "DEVLET";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow>
|
<TableRow className="kt-cv-satir">
|
||||||
<TableCell className="pl-4 pr-0">
|
<TableCell className="pl-4 pr-0">
|
||||||
{risk ? (
|
{risk ? (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ const TAM_GORUNUM = {
|
|||||||
|
|
||||||
type ViewBox = typeof 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" */
|
/** "ORTA DOĞU TEKNİK ÜNİVERSİTESİ" → "Orta Doğu Teknik" */
|
||||||
function kisaUniAdi(ad: string): string {
|
function kisaUniAdi(ad: string): string {
|
||||||
const kisa = ad
|
const kisa = ad
|
||||||
@@ -122,18 +128,54 @@ export function TercihHaritasi({
|
|||||||
/** Alt şeritte bir tercihe dokunulunca (liste satırına in) */
|
/** Alt şeritte bir tercihe dokunulunca (liste satırına in) */
|
||||||
onTercihSec?: (sira: number) => void;
|
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 [vb, setVb] = useState<ViewBox>(TAM_GORUNUM);
|
||||||
const vbRef = useRef<ViewBox>(TAM_GORUNUM);
|
const vbRef = useRef<ViewBox>(TAM_GORUNUM);
|
||||||
const animRef = useRef(0);
|
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) => {
|
const animateTo = useCallback((hedef: ViewBox) => {
|
||||||
cancelAnimationFrame(animRef.current);
|
cancelAnimationFrame(animRef.current);
|
||||||
const kaynak = { ...vbRef.current };
|
const kaynak = { ...vbRef.current };
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const SURE = 380;
|
const SURE = 380;
|
||||||
const ease = (t: number) => 1 - Math.pow(1 - t, 3);
|
const ease = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||||
|
let ilkKare = true;
|
||||||
const adim = (now: number) => {
|
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 t = Math.min(1, (now - t0) / SURE);
|
||||||
const k = ease(t);
|
const k = ease(t);
|
||||||
const sonraki = {
|
const sonraki = {
|
||||||
@@ -143,7 +185,10 @@ export function TercihHaritasi({
|
|||||||
h: kaynak.h + (hedef.h - kaynak.h) * k,
|
h: kaynak.h + (hedef.h - kaynak.h) * k,
|
||||||
};
|
};
|
||||||
vbRef.current = sonraki;
|
vbRef.current = sonraki;
|
||||||
setVb(sonraki);
|
svgRef.current?.setAttribute(
|
||||||
|
"viewBox",
|
||||||
|
`${sonraki.x} ${sonraki.y} ${sonraki.w} ${sonraki.h}`,
|
||||||
|
);
|
||||||
if (t < 1) animRef.current = requestAnimationFrame(adim);
|
if (t < 1) animRef.current = requestAnimationFrame(adim);
|
||||||
};
|
};
|
||||||
animRef.current = requestAnimationFrame(adim);
|
animRef.current = requestAnimationFrame(adim);
|
||||||
@@ -157,7 +202,7 @@ export function TercihHaritasi({
|
|||||||
animateTo(TAM_GORUNUM);
|
animateTo(TAM_GORUNUM);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const path = pathRefs.current.get(seciliIl);
|
const path = pathKayit.elemanlar.get(seciliIl);
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
const b = path.getBBox();
|
const b = path.getBBox();
|
||||||
const pad = Math.max(b.width, b.height) * 0.22;
|
const pad = Math.max(b.width, b.height) * 0.22;
|
||||||
@@ -178,7 +223,7 @@ export function TercihHaritasi({
|
|||||||
w = yeniW;
|
w = yeniW;
|
||||||
}
|
}
|
||||||
animateTo({ x, y, w, h });
|
animateTo({ x, y, w, h });
|
||||||
}, [seciliIl, animateTo]);
|
}, [seciliIl, animateTo, pathKayit.elemanlar]);
|
||||||
|
|
||||||
const ilPinleri = useMemo(() => {
|
const ilPinleri = useMemo(() => {
|
||||||
const gruplar = new Map<string, HaritaPin[]>();
|
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
|
// Ekran boyutu sabit kalsın diye SVG içi ölçüler zoom oranıyla çarpılır
|
||||||
const sf = vb.w / HARITA_GENISLIK;
|
const sf = vb.w / HARITA_GENISLIK;
|
||||||
const zoomda = Boolean(seciliIl);
|
const zoomda = Boolean(seciliIl);
|
||||||
const seciliPinler = seciliIl ? (ilPinleri.get(seciliIl) ?? []) : [];
|
const seciliPinler = useMemo(
|
||||||
const acikPinler = cakismaAc(seciliPinler);
|
() => (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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<svg
|
<svg
|
||||||
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
|
ref={svgMount}
|
||||||
className="h-auto w-full"
|
className="h-auto w-full"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Tercih listesinin Türkiye haritasındaki dağılımı"
|
aria-label="Tercih listesinin Türkiye haritasındaki dağılımı"
|
||||||
>
|
>
|
||||||
{/* İl zeminleri */}
|
{/* İl zeminleri */}
|
||||||
{cities.map((city) => {
|
{NORMAL_SEHIRLER.map(({ city, key }) => {
|
||||||
const key = normalizeIlAdi(city.name);
|
const ilAdet = ilAdetleri.get(key);
|
||||||
const iceriyor = ilPinleri.has(key);
|
const iceriyor = ilAdet !== undefined;
|
||||||
const secili = key === seciliIl;
|
const secili = key === seciliIl;
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
key={city.id}
|
key={city.id}
|
||||||
ref={(el) => {
|
ref={pathKayit.callbacks.get(key)}
|
||||||
if (el) pathRefs.current.set(key, el);
|
|
||||||
else pathRefs.current.delete(key);
|
|
||||||
}}
|
|
||||||
d={city.path}
|
d={city.path}
|
||||||
className={
|
className={
|
||||||
iceriyor
|
iceriyor
|
||||||
@@ -240,10 +323,7 @@ export function TercihHaritasi({
|
|||||||
>
|
>
|
||||||
<title>
|
<title>
|
||||||
{iceriyor
|
{iceriyor
|
||||||
? `${city.name}: ${(ilPinleri.get(key) ?? []).reduce(
|
? `${city.name}: ${ilAdet} tercih — yakınlaşmak için tıkla`
|
||||||
(a, p) => a + p.tercihSiralari.length,
|
|
||||||
0,
|
|
||||||
)} tercih — yakınlaşmak için tıkla`
|
|
||||||
: city.name}
|
: city.name}
|
||||||
</title>
|
</title>
|
||||||
</path>
|
</path>
|
||||||
@@ -252,17 +332,7 @@ export function TercihHaritasi({
|
|||||||
|
|
||||||
{/* Ülke seviyesi: il başına toplu pin */}
|
{/* Ülke seviyesi: il başına toplu pin */}
|
||||||
{!zoomda
|
{!zoomda
|
||||||
? [...ilPinleri.entries()].map(([il, iller]) => {
|
? ilOzetleri.map(({ il, adet, cx, cy, renk }) => (
|
||||||
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
|
<g
|
||||||
key={il}
|
key={il}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
@@ -291,8 +361,7 @@ export function TercihHaritasi({
|
|||||||
{adet}
|
{adet}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
))
|
||||||
})
|
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
{/* İl seviyesi: üniversite pinleri */}
|
{/* İl seviyesi: üniversite pinleri */}
|
||||||
@@ -426,12 +495,9 @@ export function TercihHaritasi({
|
|||||||
{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin
|
{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin
|
||||||
</p>
|
</p>
|
||||||
<ul className="flex gap-2 overflow-x-auto pb-1">
|
<ul className="flex gap-2 overflow-x-auto pb-1">
|
||||||
{seciliPinler
|
{seritTercihleri.map(({ pin, sira }) => {
|
||||||
.flatMap((p) =>
|
const logo = pin.kilitli ? null : uniLogoYoluFromAd(pin.universite);
|
||||||
p.tercihSiralari.map((sira) => ({ pin: p, sira })),
|
return (
|
||||||
)
|
|
||||||
.sort((a, b) => a.sira - b.sira)
|
|
||||||
.map(({ pin, sira }) => (
|
|
||||||
<li key={sira} className="shrink-0">
|
<li key={sira} className="shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -451,9 +517,9 @@ export function TercihHaritasi({
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||||
{uniLogoYoluFromAd(pin.universite) ? (
|
{logo ? (
|
||||||
<Image
|
<Image
|
||||||
src={uniLogoYoluFromAd(pin.universite)!}
|
src={logo}
|
||||||
alt=""
|
alt=""
|
||||||
width={16}
|
width={16}
|
||||||
height={16}
|
height={16}
|
||||||
@@ -468,7 +534,8 @@ export function TercihHaritasi({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -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 {
|
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. */
|
/** Senkron snapshot — giriş anı merge'inde sunucuya gönderilecek liste. */
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export function RaporListesi({
|
|||||||
else satirRefs.current.delete(t.sira);
|
else satirRefs.current.delete(t.sira);
|
||||||
}}
|
}}
|
||||||
onClick={() => tiklandi(t.sira)}
|
onClick={() => tiklandi(t.sira)}
|
||||||
className="cursor-pointer"
|
className="kt-cv-satir cursor-pointer"
|
||||||
>
|
>
|
||||||
<TableCell className="pl-4 pr-0">
|
<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">
|
<span className="flex size-7 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// il seçilen Türkiye haritası. Mavi ton ildeki program sayısını, turuncu
|
// 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.
|
// 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 { cities } from "turkey-map-react/lib/data";
|
||||||
import {
|
import {
|
||||||
HARITA_GENISLIK,
|
HARITA_GENISLIK,
|
||||||
@@ -12,6 +13,14 @@ import {
|
|||||||
normalizeIlAdi,
|
normalizeIlAdi,
|
||||||
} from "@/lib/harita";
|
} 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({
|
export function IlSecimHaritasi({
|
||||||
iller,
|
iller,
|
||||||
secili,
|
secili,
|
||||||
@@ -22,9 +31,20 @@ export function IlSecimHaritasi({
|
|||||||
secili: string[];
|
secili: string[];
|
||||||
onToggle: (il: string) => void;
|
onToggle: (il: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { adetler, maxAdet } = useMemo(() => {
|
||||||
const adetler = new Map(iller.map((i) => [i.il, i.adet]));
|
const adetler = new Map(iller.map((i) => [i.il, i.adet]));
|
||||||
const maxAdet = Math.max(1, ...iller.map((i) => i.adet));
|
let maxAdet = 1;
|
||||||
const seciliSet = new Set(secili);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -35,14 +55,7 @@ export function IlSecimHaritasi({
|
|||||||
aria-label="Okumak istediğin illeri haritadan seç"
|
aria-label="Okumak istediğin illeri haritadan seç"
|
||||||
>
|
>
|
||||||
{/* Seçili iller en son çizilir ki turuncu kontur komşularca örtülmesin */}
|
{/* Seçili iller en son çizilir ki turuncu kontur komşularca örtülmesin */}
|
||||||
{[...cities]
|
{siraliSehirler.map(({ city, key }) => {
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
Number(seciliSet.has(normalizeIlAdi(a.name))) -
|
|
||||||
Number(seciliSet.has(normalizeIlAdi(b.name))),
|
|
||||||
)
|
|
||||||
.map((city) => {
|
|
||||||
const key = normalizeIlAdi(city.name);
|
|
||||||
const adet = adetler.get(key);
|
const adet = adetler.get(key);
|
||||||
const seciliMi = seciliSet.has(key);
|
const seciliMi = seciliSet.has(key);
|
||||||
const yogunluk = adet ? adet / maxAdet : 0;
|
const yogunluk = adet ? adet / maxAdet : 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user