|
@@ -1,120 +1,87 @@
|
|
|
'use client'
|
|
'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 = '© <a href="https://www.openstreetmap.org/copyright">OSM</a>, © <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 {
|
|
export interface MapProvider {
|
|
|
- /** Инициализирует карту в указанном контейнере */
|
|
|
|
|
init(container: HTMLElement, center: [number, number], zoom: number): Promise<void>
|
|
init(container: HTMLElement, center: [number, number], zoom: number): Promise<void>
|
|
|
- /** Уничтожает карту и очищает ресурсы */
|
|
|
|
|
destroy(): void
|
|
destroy(): void
|
|
|
- /** Проверяет, готова ли карта к использованию */
|
|
|
|
|
isReady(): boolean
|
|
isReady(): boolean
|
|
|
- /** Добавляет маркер на карту */
|
|
|
|
|
addMarker(id: string, lat: number, lng: number, options?: MarkerOptions): void
|
|
addMarker(id: string, lat: number, lng: number, options?: MarkerOptions): void
|
|
|
- /** Удаляет маркер с карты по идентификатору */
|
|
|
|
|
removeMarker(id: string): void
|
|
removeMarker(id: string): void
|
|
|
- /** Устанавливает центр карты */
|
|
|
|
|
setCenter(lat: number, lng: number): void
|
|
setCenter(lat: number, lng: number): void
|
|
|
- /** Масштабирует карту, чтобы вместить указанные границы */
|
|
|
|
|
fitBounds(bounds: [[number, number], [number, number]]): void
|
|
fitBounds(bounds: [[number, number], [number, number]]): void
|
|
|
- /** Подписывается на событие перемещения карты */
|
|
|
|
|
onMove(cb: (center: [number, number], zoom: number, bounds: MapBounds) => void): 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 moveHandler: ((center: [number, number], zoom: number, bounds: MapBounds) => void) | null = null
|
|
|
- let initError: string | null = null
|
|
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
async init(container, center, zoom) {
|
|
async init(container, center, zoom) {
|
|
|
try {
|
|
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() {
|
|
isReady() {
|
|
@@ -123,65 +90,44 @@ export function createYandexMapProvider(): MapProvider {
|
|
|
|
|
|
|
|
destroy() {
|
|
destroy() {
|
|
|
markers.clear()
|
|
markers.clear()
|
|
|
- map?.destroy()
|
|
|
|
|
|
|
+ map?.remove()
|
|
|
|
|
+ map = null
|
|
|
},
|
|
},
|
|
|
|
|
|
|
|
addMarker(id, lat, lng, options) {
|
|
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)
|
|
markers.set(id, marker)
|
|
|
},
|
|
},
|
|
|
|
|
|
|
|
removeMarker(id) {
|
|
removeMarker(id) {
|
|
|
const marker = markers.get(id)
|
|
const marker = markers.get(id)
|
|
|
if (marker) {
|
|
if (marker) {
|
|
|
- map?.removeChild(marker)
|
|
|
|
|
|
|
+ marker.remove()
|
|
|
markers.delete(id)
|
|
markers.delete(id)
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
|
|
|
|
|
|
setCenter(lat, lng) {
|
|
setCenter(lat, lng) {
|
|
|
- map?.setLocation({ center: [lng, lat] })
|
|
|
|
|
|
|
+ map?.setView([lat, lng])
|
|
|
},
|
|
},
|
|
|
|
|
|
|
|
fitBounds(bounds) {
|
|
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) {
|
|
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)
|
|
|
|
|
- })
|
|
|
|
|
-}
|
|
|