Преглед на файлове

Switch from Yandex Maps to Leaflet + OSM (CartoDB dark tiles)

neyrogovnarik преди 1 месец
родител
ревизия
31367c32b9
променени са 4 файла, в които са добавени 88 реда и са изтрити 165 реда
  1. 1 1
      deploy/Caddyfile
  2. 3 2
      frontend/package.json
  3. 2 2
      frontend/src/components/MapView.tsx
  4. 82 160
      frontend/src/lib/map.ts

+ 1 - 1
deploy/Caddyfile

@@ -37,7 +37,7 @@ http://{$DOMAIN} {
 		X-Frame-Options "DENY"
 		Referrer-Policy "strict-origin-when-cross-origin"
 		Permissions-Policy "geolocation=(self), microphone=(), camera=()"
-		Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://api-maps.yandex.ru; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' wss: https://api.{$DOMAIN} https://api-maps.yandex.ru; worker-src https://api-maps.yandex.ru blob:; frame-ancestors 'none';"
+		Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com; font-src 'self' data:; connect-src 'self' wss: https://api.{$DOMAIN}; frame-ancestors 'none';"
 	}
 
 	@static {

+ 3 - 2
frontend/package.json

@@ -14,12 +14,14 @@
   "dependencies": {
     "next": "^14.1.0",
     "react": "^18.2.0",
-    "react-dom": "^18.2.0"
+    "react-dom": "^18.2.0",
+    "leaflet": "^1.9.4"
   },
   "devDependencies": {
     "@types/node": "^20.11.0",
     "@types/react": "^18.2.0",
     "@types/react-dom": "^18.2.0",
+    "@types/leaflet": "^1.9.12",
     "typescript": "^5.3.0",
     "eslint": "^8.56.0",
     "eslint-config-next": "^14.1.0",
@@ -28,7 +30,6 @@
     "postcss": "^8.4.0",
     "prettier": "^3.2.0",
     "prettier-plugin-tailwindcss": "^0.5.0",
-    "@yandex/ymaps3-types": "latest",
     "vitest": "^1.0.0",
     "@testing-library/react": "^14.0.0",
     "@testing-library/jest-dom": "^6.0.0",

+ 2 - 2
frontend/src/components/MapView.tsx

@@ -2,7 +2,7 @@
 
 import { useEffect, useRef, useState, useCallback } from 'react'
 
-import { createYandexMapProvider, type MapProvider, type MapBounds } from '@/lib/map'
+import { createLeafletMapProvider, type MapProvider, type MapBounds } from '@/lib/map'
 import { useAuth } from '@/hooks/useAuth'
 import { useWebSocket } from '@/hooks/useWebSocket'
 import type { Place } from '@/types'
@@ -45,7 +45,7 @@ export default function MapView() {
   useEffect(() => {
     if (!containerRef.current || mapRef.current) return
 
-    const provider = createYandexMapProvider()
+    const provider = createLeafletMapProvider()
     mapRef.current = provider
 
     provider.init(containerRef.current, [37.6173, 55.7558], 10).then(() => {

+ 82 - 160
frontend/src/lib/map.ts

@@ -1,120 +1,87 @@
 'use client'
 
+import L from 'leaflet'
+import 'leaflet/dist/leaflet.css'
 
-declare global {
-  interface Window {
-    ymaps3?: typeof import('@yandex/ymaps3-types')
-  }
+export interface MapBounds {
+  swLat: number
+  swLng: number
+  neLat: number
+  neLng: number
+}
+
+export interface MarkerOptions {
+  type?: 'place' | 'studio' | 'visitor'
+  title?: string
+  onClick?: () => void
+}
+
+const TILE_URL = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
+const TILE_ATTR = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>, &copy; <a href="https://carto.com/">CARTO</a>'
+
+const MARKER_COLORS: Record<string, string> = {
+  place: '#3b82f6',
+  studio: '#a855f7',
+  visitor: '#22c55e',
+}
+
+function createIcon(type: string, size: number): L.DivIcon {
+  const color = MARKER_COLORS[type] || '#3b82f6'
+  return L.divIcon({
+    className: '',
+    iconSize: [size, size],
+    iconAnchor: [size / 2, size / 2],
+    html: `<div style="width:${size}px;height:${size}px;background:${color};border-radius:50%;border:${size > 20 ? '3px' : '2px'} solid white;box-shadow:0 2px 8px rgba(0,0,0,0.3);transition:transform 0.2s"></div>`,
+  })
 }
 
-/**
- * Абстракция над картографическим провайдером.
- * Предоставляет единый интерфейс для управления картой, маркерами и подписки на события.
- */
 export interface MapProvider {
-  /** Инициализирует карту в указанном контейнере */
   init(container: HTMLElement, center: [number, number], zoom: number): Promise<void>
-  /** Уничтожает карту и очищает ресурсы */
   destroy(): void
-  /** Проверяет, готова ли карта к использованию */
   isReady(): boolean
-  /** Добавляет маркер на карту */
   addMarker(id: string, lat: number, lng: number, options?: MarkerOptions): void
-  /** Удаляет маркер с карты по идентификатору */
   removeMarker(id: string): void
-  /** Устанавливает центр карты */
   setCenter(lat: number, lng: number): void
-  /** Масштабирует карту, чтобы вместить указанные границы */
   fitBounds(bounds: [[number, number], [number, number]]): void
-  /** Подписывается на событие перемещения карты */
   onMove(cb: (center: [number, number], zoom: number, bounds: MapBounds) => void): void
 }
 
-/**
- * Опции маркера на карте
- * @property type - Тип маркера (определяет цвет и размер)
- * @property title - Всплывающая подсказка (опционально)
- * @property onClick - Обработчик клика по маркеру (опционально)
- */
-export interface MarkerOptions {
-  type?: 'place' | 'studio' | 'visitor'
-  title?: string
-  onClick?: () => void
-}
-
-/**
- * Прямоугольные границы видимой области карты
- * @property swLat - Широта юго-западного угла
- * @property swLng - Долгота юго-западного угла
- * @property neLat - Широта северо-восточного угла
- * @property neLng - Долгота северо-восточного угла
- */
-export interface MapBounds {
-  swLat: number
-  swLng: number
-  neLat: number
-  neLng: number
-}
-
-/**
- * Создаёт экземпляр MapProvider на основе Яндекс.Карт API v3.
- * При первом вызове асинхронно загружает скрипт Яндекс.Карт.
- * @returns Объект с интерфейсом MapProvider для управления картой
- */
-export function createYandexMapProvider(): MapProvider {
-  let map: any = null
-  let ymaps3: any = null
-  let markers = new Map<string, any>()
+export function createLeafletMapProvider(): MapProvider {
+  let map: L.Map | null = null
+  let markers = new Map<string, L.Marker>()
   let moveHandler: ((center: [number, number], zoom: number, bounds: MapBounds) => void) | null = null
-  let initError: string | null = null
 
   return {
     async init(container, center, zoom) {
       try {
-        await loadYandexScript()
-
-        ymaps3 = await (window as any).ymaps3.ready
-        await ymaps3.import('@yandex/ymaps3-markers@0.0.1')
-      } catch (e) {
-        initError = 'Failed to load Yandex Maps'
-        return
-      }
-
-      map = new ymaps3.YMap(container, {
-        location: { center, zoom },
-        mode: 'vector',
-        theme: 'dark',
-      })
-
-      map.addChild(new ymaps3.YMapDefaultSchemeLayer({
-        customization: [
-          { tags: { any: ['poi', 'road', 'landscape'] }, elements: 'geometry', stylers: [{ saturation: -100 }] }
-        ]
-      }))
-
-      map.addChild(new ymaps3.YMapDefaultFeaturesLayer())
-
-      const updateBounds = () => {
-        if (!moveHandler) return
-        const loc = map.location
-        const bounds = map.getBounds()
-        moveHandler(
-          loc.center as [number, number],
-          loc.zoom,
-          {
-            swLat: bounds[0][1],
-            swLng: bounds[0][0],
-            neLat: bounds[1][1],
-            neLng: bounds[1][0],
-          }
+        map = L.map(container, { zoomControl: true }).setView(
+          [center[1], center[0]],
+          zoom,
         )
-      }
-
-      map.addChild(new ymaps3.YMapFeatureListener({
-        onUpdate: updateBounds,
-      }))
 
-      updateBounds()
+        L.tileLayer(TILE_URL, {
+          attribution: TILE_ATTR,
+          maxZoom: 19,
+        }).addTo(map)
+
+        map.on('moveend', () => {
+          if (!moveHandler || !map) return
+          const c = map.getCenter()
+          const b = map.getBounds()
+          moveHandler(
+            [c.lng, c.lat],
+            map.getZoom(),
+            {
+              swLat: b.getSouthWest().lat,
+              swLng: b.getSouthWest().lng,
+              neLat: b.getNorthEast().lat,
+              neLng: b.getNorthEast().lng,
+            },
+          )
+        })
+      } catch {
+        map = null
+      }
     },
 
     isReady() {
@@ -123,65 +90,44 @@ export function createYandexMapProvider(): MapProvider {
 
     destroy() {
       markers.clear()
-      map?.destroy()
+      map?.remove()
+      map = null
     },
 
     addMarker(id, lat, lng, options) {
-      if (!ymaps3 || !map) return
-
-      const color = options?.type === 'visitor' ? '#22c55e'
-        : options?.type === 'studio' ? '#a855f7'
-        : '#3b82f6'
-
-      const marker = new ymaps3.YMapMarker({
-        coordinates: [lng, lat],
-        onClick: options?.onClick,
-        source: 'my',
-        zIndex: options?.type === 'visitor' ? 10 : 100,
-      })
-
-      const el = document.createElement('div')
-      el.className = `map-marker ${options?.type || 'place'}`
-      el.style.cssText = `
-        width: ${options?.type === 'visitor' ? '12px' : '32px'};
-        height: ${options?.type === 'visitor' ? '12px' : '32px'};
-        background: ${color};
-        border-radius: 50%;
-        border: ${options?.type === 'visitor' ? '2px' : '3px'} solid white;
-        box-shadow: 0 2px 8px rgba(0,0,0,0.3);
-        cursor: ${options?.type === 'visitor' ? 'default' : 'pointer'};
-        transform: translate(-50%, -50%);
-        transition: transform 0.2s;
-      `
-
-      el.addEventListener('click', (e) => {
-        e.stopPropagation()
-        options?.onClick?.()
-      })
-
-      marker.element = el
-      marker.addChild(new ymaps3.YMapMarkerContent({ element: el }))
-
-      map.addChild(marker)
+      if (!map) return
+      const type = options?.type || 'place'
+      const size = type === 'visitor' ? 12 : 32
+      const icon = createIcon(type, size)
+      const marker = L.marker([lat, lng], { icon }).addTo(map)
+
+      if (options?.title) {
+        marker.bindTooltip(options.title, { direction: 'top' })
+      }
+      if (options?.onClick) {
+        marker.on('click', () => options.onClick?.())
+      }
+
       markers.set(id, marker)
     },
 
     removeMarker(id) {
       const marker = markers.get(id)
       if (marker) {
-        map?.removeChild(marker)
+        marker.remove()
         markers.delete(id)
       }
     },
 
     setCenter(lat, lng) {
-      map?.setLocation({ center: [lng, lat] })
+      map?.setView([lat, lng])
     },
 
     fitBounds(bounds) {
-      map?.setLocation({
-        bounds: [[bounds[0][0], bounds[0][1]], [bounds[1][0], bounds[1][1]]]
-      })
+      map?.fitBounds([
+        [bounds[0][1], bounds[0][0]],
+        [bounds[1][1], bounds[1][0]],
+      ])
     },
 
     onMove(cb) {
@@ -189,27 +135,3 @@ export function createYandexMapProvider(): MapProvider {
     },
   }
 }
-
-/**
- * Загружает скрипт Яндекс.Карт API v3, если он ещё не загружен.
- * Использует API-ключ из переменной окружения NEXT_PUBLIC_YANDEX_MAPS_API_KEY.
- * @returns Promise, который разрешается после загрузки скрипта
- */
-async function loadYandexScript(): Promise<void> {
-  if (document.querySelector('script[src*="yandexmaps"]')) return
-
-  const apiKey = process.env.NEXT_PUBLIC_YANDEX_MAPS_API_KEY
-  if (!apiKey) return
-
-  return new Promise((resolve) => {
-    const script = document.createElement('script')
-    script.src = `https://api-maps.yandex.ru/v3/?apikey=${apiKey}&lang=ru_RU`
-    script.async = true
-    script.onload = () => resolve()
-    script.onerror = () => {
-      console.warn('Yandex Maps script failed to load')
-      resolve()
-    }
-    document.head.appendChild(script)
-  })
-}