buhapp-telegram/public/js/views/map.js
2026-08-21 11:20:37 +00:00

419 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);"></div>',
18
);
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;