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

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

View File

@@ -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 yon === "artan" ? fark : -fark;
return fark === 0 ? a.index - b.index : yon === "artan" ? fark : -fark; });
})
.map(({ program }) => program);
}, [aktifAlan, programlar, yon]); }, [aktifAlan, programlar, yon]);
const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa; const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa;
@@ -129,18 +127,22 @@ 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) {
setIstemciSayfa(1); startTransition(() => {
if (aktifAlan === alan) { setIstemciSayfa(1);
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan")); if (aktifAlan === alan) {
return; setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
} return;
setAktifAlan(alan); }
setYon("artan"); setAktifAlan(alan);
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 ? (

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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,47 +332,36 @@ 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( <g
(a, p) => a + p.tercihSiralari.length, key={il}
0, className="cursor-pointer"
); onClick={() => onSeciliIlDegisti(il)}
const cx = role="button"
iller.reduce((a, p) => a + p.x, 0) / iller.length; aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
const cy = >
iller.reduce((a, p) => a + p.y, 0) / iller.length; <circle
const renk = RISK_RENK[baskinRisk(iller)]; cx={cx}
return ( cy={cy}
<g r={11}
key={il} fill={renk}
className="cursor-pointer" stroke="white"
onClick={() => onSeciliIlDegisti(il)} strokeWidth={2}
role="button" />
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`} <text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fontWeight={700}
fill="white"
className="pointer-events-none select-none"
> >
<circle {adet}
cx={cx} </text>
cy={cy} </g>
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>
);
})
: 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}

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 { 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. */

View File

@@ -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">

View File

@@ -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 = new Map(iller.map((i) => [i.il, i.adet])); const { adetler, maxAdet } = useMemo(() => {
const maxAdet = Math.max(1, ...iller.map((i) => i.adet)); const adetler = new Map(iller.map((i) => [i.il, i.adet]));
const seciliSet = new Set(secili); 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 ( 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;