buhapp-telegram/public/js/views/map.js
root 06c8f8b593 Fix: tab switching hangs on iOS Safari
- showView: requestAnimationFrame + мгновенный loader (UI не висит)
- maybeOnboard на старте fire-and-forget (не блокирует init)
- MapView: НЕ запрашивает геолокацию автоматически (только по кнопке)
- MapView: правильно destroy map если container удалён (нет утечки Leaflet)
- invalidateSize() перед setView чтобы тайлы 2GIS догрузились
2026-08-20 21:28:33 +00:00

366 lines
15 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 — главный экран
const MapView = {
map: null,
markers: [],
placeMarkers: [], // маркеры баров/ресторанов
placeMarkersLayer: null,
radius: 5,
visible: true,
nearby: [],
places: [],
showPlaces: true,
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;"></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 привязан к удалённому div — убьём его и создадим новый
if (this.map) {
try {
const container = this.map.getContainer();
if (!container || !document.body.contains(container)) {
this.map.remove();
this.map = null;
this.placeMarkersLayer = null;
this.markers = [];
}
} catch (e) {
this.map = null;
}
}
if (!this.map) {
this.map = L.map('map').setView([55.7558, 37.6173], 12);
// 2GIS tile layer
L.tileLayer('https://tile2.maps.2gis.com/tiles?x={x}&y={y}&z={z}', {
attribution: '© 2GIS',
maxZoom: 19,
}).addTo(this.map);
}
setTimeout(() => this.map.invalidateSize(), 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);
document.getElementById('radius-label') && (document.getElementById('radius-label').textContent = this.radius + ' км');
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 ? 'Скрыть бары' : 'Показать бары';
if (this.placeMarkersLayer) {
if (this.showPlaces) this.map.addLayer(this.placeMarkersLayer);
else this.map.removeLayer(this.placeMarkersLayer);
}
const list = document.getElementById('places-list');
if (list) list.style.display = this.showPlaces ? 'block' : 'none';
};
// если уже есть сохранённые координаты — покажем на карте (быстро)
if (window._lastKnownPos) {
this.applyPosition(window._lastKnownPos);
}
// НЕ запрашиваем автоматически — это блокирует UI на iOS Safari.
// Юзер нажмёт кнопку "📍 Поделиться геолокацией".
},
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 'Неизвестная ошибка геолокации.';
}
},
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) {
// важно: invalidateSize() чтобы Leaflet пересчитал размеры (после смены таба лейаут мог обнулиться)
this.map.invalidateSize();
// убираем старый self-marker и круг
if (this.selfMarker) { this.map.removeLayer(this.selfMarker); this.selfMarker = null; }
this.map.eachLayer((layer) => {
if (layer instanceof L.Circle && layer.options && layer.options._isRadius) {
this.map.removeLayer(layer);
}
});
// маркер "Я"
const meIcon = L.divIcon({
className: 'self-marker',
html: '<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>',
iconSize: [18, 18],
iconAnchor: [9, 9],
});
this.selfMarker = L.marker([lat, lng], { icon: meIcon, zIndexOffset: 1000 }).addTo(this.map);
// круг радиуса
L.circle([lat, lng], {
radius: this.radius * 1000,
color: '#FF6B35',
fillColor: '#FF6B35',
fillOpacity: 0.1,
weight: 2,
_isRadius: true,
}).addTo(this.map);
// центр + zoom на мне
this.map.setView([lat, lng], 14, { animate: true });
this.refreshNearbyFromPos(lat, lng);
this.refreshPlacesFromPos(lat, lng);
}
},
async refreshNearby() {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(async (pos) => {
this.refreshNearbyFromPos(pos.coords.latitude, pos.coords.longitude);
});
},
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.markers.forEach(m => this.map.removeLayer(m));
this.markers = [];
this.nearby.forEach((n) => {
const icon = L.divIcon({
className: 'custom-marker',
html: `<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>`,
iconSize: [36, 36],
});
const m = L.marker([n.lat, n.lng], { icon }).addTo(this.map);
m.bindTooltip(n.name + ' • ' + (n.distance_m / 1000).toFixed(1) + ' км', { direction: 'top' });
m.on('click', () => App.openUserProfile(n.user_id, n.name));
this.markers.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 || [];
// маркеры
if (this.placeMarkersLayer) this.map.removeLayer(this.placeMarkersLayer);
this.placeMarkersLayer = L.layerGroup();
this.places.forEach((p) => {
const icon = L.divIcon({
className: 'place-marker',
html: `<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>`,
iconSize: [32, 32],
});
const m = L.marker([p.lat, p.lng], { icon });
m.bindTooltip(
`<b>${escapeHtml(p.name)}</b><br>${placeTypeName(p.type)}${(p.distance_m/1000).toFixed(1)} км${p.cuisine ? '<br>🍴 ' + escapeHtml(p.cuisine) : ''}`,
{ direction: 'top' }
);
m.on('click', () => this.openPlaceCard(p));
this.placeMarkersLayer.addLayer(m);
});
if (this.showPlaces) this.placeMarkersLayer.addTo(this.map);
// список снизу
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;
}
// простая реализация — показать список через confirm
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 {
await API.ChatApi.sendMessage(target.chat.id || target.chat.id, 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;