// Map view — MapLibre GL (замена Leaflet) // Раньше: Leaflet + OSM тайлы = медленно, блокируется // Сейчас: MapLibre GL + 2GIS raster tiles = WebGL, быстро const MapView = { map: null, selfMarkerEl: null, nearbyMarkers: [], placeMarkers: [], radius: 5, visible: true, nearby: [], places: [], showPlaces: true, // стиль карты — только raster source 2GIS (без API ключа) getMAP_STYLE() { return { version: 8, sources: { '2gis': { type: 'raster', tiles: [ 'https://tile2.maps.2gis.com/tiles?x={x}&y={y}&z={z}', ], tileSize: 256, attribution: '© 2GIS', maxzoom: 19, }, }, layers: [ { id: '2gis', type: 'raster', source: '2gis', minzoom: 0, maxzoom: 22 }, ], }; }, async render(root) { root.innerHTML = `

🗺️ Карта

Я на карте
Другие пользователи видят твоё местоположение ±300м
Нажми чтобы получить координаты
—
`; // убиваем старый map если его контейнер удалён if (this.map) { try { const container = this.map.getContainer(); if (!container || !document.body.contains(container)) { this.map.remove(); this.map = null; } } catch (e) { this.map = null; } } if (!this.map) { this.map = new maplibregl.Map({ container: 'map', style: this.getMAP_STYLE(), center: [37.6173, 55.7558], zoom: 11, attributionControl: true, }); this.map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right'); } // invalidateSize — MapLibre делает это через resize() setTimeout(() => { try { this.map.resize(); } catch (e) {} }, 100); document.getElementById('visible-toggle').addEventListener('change', async (e) => { this.visible = e.target.checked; try { await API.Auth.setVisibility(this.visible); if (window._lastKnownPos) this.applyPosition(window._lastKnownPos); } catch (err) { console.warn(err); TG_APP.showAlert('Не удалось обновить видимость'); } }); document.getElementById('radius-sel').addEventListener('change', async (e) => { this.radius = parseInt(e.target.value, 10); if (window._lastKnownPos) { await this.refreshNearbyFromPos(window._lastKnownPos.coords.latitude, window._lastKnownPos.coords.longitude); await this.refreshPlacesFromPos(window._lastKnownPos.coords.latitude, window._lastKnownPos.coords.longitude); } }); document.getElementById('loc-share-btn').onclick = (e) => { e.preventDefault(); this.requestLocation(); }; document.getElementById('places-toggle').onclick = () => { this.showPlaces = !this.showPlaces; const txt = document.getElementById('places-toggle-text'); if (txt) txt.textContent = this.showPlaces ? 'Скрыть бары' : 'Показать бары'; this.placeMarkers.forEach((m) => { const el = m.getElement(); if (el) el.style.display = this.showPlaces ? 'block' : 'none'; }); const list = document.getElementById('places-list'); if (list) list.style.display = this.showPlaces ? 'block' : 'none'; }; if (window._lastKnownPos) { this.applyPosition(window._lastKnownPos); } }, requestLocation() { if (!navigator.geolocation) { const s = document.getElementById('loc-status'); if (s) s.textContent = 'Геолокация недоступна в браузере'; return; } const btn = document.getElementById('loc-share-btn'); const status = document.getElementById('loc-status'); if (btn) { btn.disabled = true; btn.textContent = '⏳ Запрашиваю…'; } if (status) status.textContent = 'Браузер спросит разрешение…'; navigator.geolocation.getCurrentPosition( async (pos) => { window._lastKnownPos = pos; if (btn) { btn.disabled = false; btn.innerHTML = '📍 Обновить геолокацию'; } if (status) status.textContent = `✓ ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`; await this.applyPosition(pos); }, (err) => { if (btn) { btn.disabled = false; btn.innerHTML = '📍 Попробовать снова'; } if (status) { const msg = this.geolocationErrorText(err); status.innerHTML = '⚠️ ' + msg + (err.code === 1 ? '
Открой Настройки → Сайты → Геолокация → buhapp.mygoodservice.ru' : ''); } console.warn('geolocation error:', err); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 } ); }, geolocationErrorText(err) { switch (err.code) { case err.PERMISSION_DENIED: return 'Доступ к геолокации запрещён.'; case err.POSITION_UNAVAILABLE: return 'Не удалось определить координаты (нет GPS/Wi-Fi).'; case err.TIMEOUT: return 'Таймаут запроса геолокации.'; default: return 'Неизвестная ошибка геолокации.'; } }, // helper — создать HTML маркер для MapLibre makeMarkerEl(html, size = 36) { const el = document.createElement('div'); el.className = 'ml-marker'; el.style.cssText = 'width:' + size + 'px;height:' + size + 'px;display:flex;align-items:center;justify-content:center;cursor:pointer;'; el.innerHTML = html; return el; }, async applyPosition(pos) { const lat = pos.coords.latitude; const lng = pos.coords.longitude; try { await API.Auth.updateLocation(lat, lng, this.visible); } catch (e) { console.warn('updateLocation:', e); } if (!this.map) return; try { this.map.resize(); } catch (e) {} // убираем старый selfMarker if (this.selfMarkerEl) { this.selfMarkerEl.remove(); this.selfMarkerEl = null; } // старый круг радиуса (через source/layer) if (this.map.getLayer && this.map.getLayer('radius-circle')) { this.map.removeLayer('radius-circle'); this.map.removeSource('radius-circle'); } // маркер "Я" (клик → свой профиль) this.selfMarkerEl = this.makeMarkerEl( '
', 18 ); this.selfMarkerEl.addEventListener('click', () => { const me = API.Store.user; if (me && (me.id || me.user_id)) { App.showView('profile'); } }); this.selfMarkerObj = new maplibregl.Marker({ element: this.selfMarkerEl }) .setLngLat([lng, lat]) .addTo(this.map); // круг радиуса — добавляем через GeoJSON source const radiusKm = this.radius; const radiusM = radiusKm * 1000; // создаём круг из 64 точек вокруг центра const center = [lng, lat]; const coords = []; const earthR = 6378137; const d2r = Math.PI / 180; const r2d = 180 / Math.PI; for (let i = 0; i <= 64; i++) { const bearing = (i / 64) * 360; const lat1 = lat * d2r; const lng1 = lng * d2r; const brng = bearing * d2r; const lat2 = Math.asin(Math.sin(lat1) * Math.cos(radiusM / earthR) + Math.cos(lat1) * Math.sin(radiusM / earthR) * Math.cos(brng)); const lng2 = lng1 + Math.atan2(Math.sin(brng) * Math.sin(radiusM / earthR) * Math.cos(lat1), Math.cos(radiusM / earthR) - Math.sin(lat1) * Math.sin(lat2)); coords.push([lng2 * r2d, lat2 * r2d]); } this.map.addSource('radius-circle', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] }, properties: {}, }, }); this.map.addLayer({ id: 'radius-circle', type: 'fill', source: 'radius-circle', paint: { 'fill-color': '#FF6B35', 'fill-opacity': 0.12, 'fill-outline-color': '#FF6B35', }, }); // центр + zoom this.map.flyTo({ center: [lng, lat], zoom: 14, speed: 1.4 }); this.refreshNearbyFromPos(lat, lng); this.refreshPlacesFromPos(lat, lng); }, async refreshNearbyFromPos(lat, lng) { try { const r = await API.Auth.searchNearby(lat, lng, this.radius); this.nearby = r.results || []; const c = document.getElementById('nearby-count'); if (c) c.textContent = '👥 ' + this.nearby.length + ' чел. рядом'; // убираем старые маркеры людей this.nearbyMarkers.forEach((m) => m.remove()); this.nearbyMarkers = []; this.nearby.forEach((n) => { const el = this.makeMarkerEl( '
' + escapeHtml((n.name || '?')[0]) + '
' ); el.title = (n.name || '') + ' • ' + (n.distance_m / 1000).toFixed(1) + ' км'; el.onclick = () => App.openUserProfile(n.user_id, n.name); const m = new maplibregl.Marker({ element: el }) .setLngLat([n.lng, n.lat]) .addTo(this.map); this.nearbyMarkers.push(m); }); } catch (e) { const c = document.getElementById('nearby-count'); if (c) c.textContent = '📍 нужна геолокация'; console.warn(e); } }, async refreshPlacesFromPos(lat, lng) { try { const r = await API.PlacesApi.nearby(lat, lng, this.radius, 50); this.places = r.places || []; // убираем старые маркеры мест this.placeMarkers.forEach((m) => m.remove()); this.placeMarkers = []; this.places.forEach((p) => { const el = this.makeMarkerEl( '
' + placeEmoji(p.type) + '
' ); el.title = (p.name || '') + ' • ' + (p.distance_m/1000).toFixed(1) + ' км'; el.onclick = () => this.openPlaceCard(p); const m = new maplibregl.Marker({ element: el }) .setLngLat([p.lng, p.lat]) .addTo(this.map); this.placeMarkers.push(m); }); this.renderPlacesList(); } catch (e) { console.warn('refreshPlaces:', e); } }, renderPlacesList() { const box = document.getElementById('places-list'); if (!box) return; if (!this.places.length) { box.innerHTML = '
Бары рядом не найдены. Попробуй увеличить радиус.
'; return; } box.innerHTML = '
🍻 Где выпить / поесть:
' + this.places.map((p) => `
${placeEmoji(p.type)}
${escapeHtml(p.name)}
${placeTypeName(p.type)} • ${(p.distance_m/1000).toFixed(1)} км${p.cuisine ? ' • ' + escapeHtml(p.cuisine) : ''}
`).join(''); box.querySelectorAll('.card.clickable').forEach((el) => { el.onclick = () => { const id = parseInt(el.dataset.placeId, 10); const p = this.places.find(x => x.osm_id === id); if (p) this.openPlaceCard(p); }; }); box.querySelectorAll('[data-invite-place]').forEach((btn) => { btn.onclick = (e) => { e.stopPropagation(); const id = parseInt(btn.dataset.invitePlace, 10); const p = this.places.find(x => x.osm_id === id); if (p) this.inviteToPlace(p); }; }); }, openPlaceCard(p) { const msg = `${p.name} (${placeTypeName(p.type)})\n📍 ${p.lat.toFixed(4)}, ${p.lng.toFixed(4)}${p.cuisine ? '\n🍴 ' + p.cuisine : ''}${p.hours ? '\n🕐 ' + p.hours : ''}${p.phone ? '\n📞 ' + p.phone : ''}`; TG_APP.showAlert(msg); }, async inviteToPlace(p) { const chats = await API.ChatApi.listChats(); if (!chats.chats || !chats.chats.length) { TG_APP.showAlert('Сначала начни чат с кем-нибудь'); return; } const list = chats.chats.slice(0, 5).map((c, i) => `${i+1}. ${c.other_name || c.otherName || 'собеседник'}`).join('\n'); TG_APP.showConfirm( `Отправить предложение встретиться в "${p.name}"?\n\nТвои чаты:\n${list}\n\nПредложение будет отправлено в самый новый чат.` ).then(async (ok) => { if (!ok) return; const target = chats.chats[0]; const text = `🍻 Давай встретимся здесь: ${p.name}\n📍 https://2gis.ru/?m=${p.lng.toFixed(6)}%2C${p.lat.toFixed(6)}%2F16\n${p.address ? '🏠 ' + p.address : ''}`; try { const chatId = target.chat?.id || target.id; await API.ChatApi.sendMessage(chatId, text); TG_APP.showAlert('Приглашение отправлено в чат с ' + (target.other_name || 'собеседником')); } catch (e) { TG_APP.showAlert('Ошибка: ' + e.message); } }); }, }; function placeEmoji(type) { switch (type) { case 'bar': case 'pub': case 'biergarten': return '🍺'; case 'restaurant': return '🍽️'; case 'cafe': return '☕'; case 'nightclub': return '🎉'; default: return '🍴'; } } function placeTypeName(type) { switch (type) { case 'bar': return 'Бар'; case 'pub': return 'Паб'; case 'biergarten': return 'Пивная'; case 'restaurant': return 'Ресторан'; case 'cafe': return 'Кафе'; case 'nightclub': return 'Ночной клуб'; default: return 'Заведение'; } } window.MapView = MapView;