425 lines
16 KiB
JavaScript
425 lines
16 KiB
JavaScript
// 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 = `
|
||
<div class="view">
|
||
<h1>🗺️ Карта</h1>
|
||
<div class="card" id="my-info">
|
||
<div class="row">
|
||
<div>
|
||
<strong>Я на карте</strong>
|
||
<div class="privacy-note">Другие пользователи видят твоё местоположение ±300м</div>
|
||
</div>
|
||
<label class="switch">
|
||
<input type="checkbox" id="visible-toggle" ${this.visible ? 'checked' : ''}>
|
||
<span class="slider"></span>
|
||
</label>
|
||
</div>
|
||
<button class="btn" id="loc-share-btn" style="margin-top:8px;">
|
||
<span class="btn-pulse">📍</span> Поделиться геолокацией
|
||
</button>
|
||
<div class="privacy-note" id="loc-status">Нажми чтобы получить координаты</div>
|
||
</div>
|
||
<div id="map" style="height:340px;border-radius:12px;overflow:hidden;margin-bottom:12px;position:relative;"></div>
|
||
<div class="row" style="gap:8px;flex-wrap:wrap;">
|
||
<button class="btn" id="places-toggle" style="width:auto;flex:1;margin:0;font-size:14px;padding:10px;">
|
||
🍻 <span id="places-toggle-text">Скрыть бары</span>
|
||
</button>
|
||
<select id="radius-sel" style="background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:10px;font-size:14px;">
|
||
<option value="1">1 км</option>
|
||
<option value="3">3 км</option>
|
||
<option value="5" selected>5 км</option>
|
||
<option value="10">10 км</option>
|
||
<option value="25">25 км</option>
|
||
</select>
|
||
</div>
|
||
<div id="nearby-count" class="sub" style="margin-top:8px;">—</div>
|
||
<div id="places-list" style="margin-top:8px;"></div>
|
||
</div>
|
||
`;
|
||
|
||
// убиваем старый 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 = '<span class="btn-pulse">📍</span> Обновить геолокацию';
|
||
}
|
||
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 = '<span class="btn-pulse">📍</span> Попробовать снова';
|
||
}
|
||
if (status) {
|
||
const msg = this.geolocationErrorText(err);
|
||
status.innerHTML = '⚠️ ' + msg + (err.code === 1
|
||
? ' <br><small>Открой Настройки → Сайты → Геолокация → buhapp.mygoodservice.ru</small>'
|
||
: '');
|
||
}
|
||
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(
|
||
'<div style="width:18px;height:18px;border-radius:50%;background:#4285F4;border:3px solid white;box-shadow:0 0 0 2px rgba(66,133,244,0.4);cursor:pointer;"></div>',
|
||
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(
|
||
'<div style="width:36px;height:36px;border-radius:50%;background:#FF6B35;color:white;display:flex;align-items:center;justify-content:center;font-weight:700;border:2px solid white;">' +
|
||
escapeHtml((n.name || '?')[0]) +
|
||
'</div>'
|
||
);
|
||
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(
|
||
'<div style="width:32px;height:32px;border-radius:8px;background:#FFD8C2;color:#FF6B35;display:flex;align-items:center;justify-content:center;font-weight:700;border:2px solid #FF6B35;font-size:18px;">' +
|
||
placeEmoji(p.type) +
|
||
'</div>'
|
||
);
|
||
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 = '<div class="empty" style="padding:12px;">Бары рядом не найдены. Попробуй увеличить радиус.</div>';
|
||
return;
|
||
}
|
||
box.innerHTML = '<div class="sub" style="margin-bottom:8px;">🍻 Где выпить / поесть:</div>' +
|
||
this.places.map((p) => `
|
||
<div class="card clickable" data-place-id="${p.osm_id}" style="padding:10px;display:flex;align-items:center;gap:10px;">
|
||
<div style="width:40px;height:40px;border-radius:10px;background:#FFD8C2;display:flex;align-items:center;justify-content:center;font-size:22px;flex-shrink:0;">${placeEmoji(p.type)}</div>
|
||
<div style="flex:1;min-width:0;">
|
||
<div style="font-weight:600;font-size:14px;">${escapeHtml(p.name)}</div>
|
||
<div class="privacy-note">${placeTypeName(p.type)} • ${(p.distance_m/1000).toFixed(1)} км${p.cuisine ? ' • ' + escapeHtml(p.cuisine) : ''}</div>
|
||
</div>
|
||
<button class="btn ghost" data-invite-place="${p.osm_id}" style="width:auto;margin:0;padding:6px 10px;font-size:12px;">📨</button>
|
||
</div>
|
||
`).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;
|