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 {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
@@ -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;
|
||||
// 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 fark === 0 ? a.index - b.index : yon === "artan" ? fark : -fark;
|
||||
})
|
||||
.map(({ program }) => program);
|
||||
return yon === "artan" ? fark : -fark;
|
||||
});
|
||||
}, [aktifAlan, programlar, yon]);
|
||||
|
||||
const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa;
|
||||
@@ -129,7 +127,10 @@ 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) {
|
||||
startTransition(() => {
|
||||
setIstemciSayfa(1);
|
||||
if (aktifAlan === alan) {
|
||||
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
|
||||
@@ -137,10 +138,11 @@ export function BolumProgramListesi({
|
||||
}
|
||||
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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,17 +332,7 @@ 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 (
|
||||
? ilOzetleri.map(({ il, adet, cx, cy, renk }) => (
|
||||
<g
|
||||
key={il}
|
||||
className="cursor-pointer"
|
||||
@@ -291,8 +361,7 @@ export function TercihHaritasi({
|
||||
{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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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, maxAdet } = useMemo(() => {
|
||||
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);
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user