Spaces:
Sleeping
Sleeping
| from flask import Flask, Response, request, jsonify | |
| import json | |
| import os | |
| import hmac | |
| import hashlib | |
| from urllib.parse import unquote | |
| app = Flask(__name__) | |
| HOTSPOTS_FILE = 'hotspots.json' | |
| BOT_TOKEN = '6909363967:AAGl58czIt7Vra_V8wWR7MyXkN_ayS27Soo' | |
| def get_all_hotspots(): | |
| if not os.path.exists(HOTSPOTS_FILE): | |
| return [] | |
| try: | |
| with open(HOTSPOTS_FILE, 'r', encoding='utf-8') as f: | |
| return json.load(f) | |
| except (json.JSONDecodeError, FileNotFoundError): | |
| return [] | |
| def save_hotspot(new_hotspot): | |
| hotspots = get_all_hotspots() | |
| hotspots.append(new_hotspot) | |
| with open(HOTSPOTS_FILE, 'w', encoding='utf-8') as f: | |
| json.dump(hotspots, f, ensure_ascii=False, indent=4) | |
| def is_data_safe(init_data: str) -> (bool, dict): | |
| try: | |
| encoded_data = unquote(init_data) | |
| data_check_string = [] | |
| recieved_hash = '' | |
| for pair in encoded_data.split('&'): | |
| key, value = pair.split('=', 1) | |
| if key == 'hash': | |
| recieved_hash = value | |
| else: | |
| data_check_string.append(f"{key}={value}") | |
| data_check_string.sort() | |
| data_check_string = "\n".join(data_check_string) | |
| secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest() | |
| calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() | |
| if calculated_hash == recieved_hash: | |
| data = {k: v for k, v in [pair.split('=', 1) for pair in encoded_data.split('&')]} | |
| user_data = json.loads(unquote(data['user'])) | |
| return True, user_data | |
| return False, None | |
| except Exception: | |
| return False, None | |
| def index(): | |
| html_content = ''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>TON AR Hotspots</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover"> | |
| <script src="https://telegram.org/js/telegram-web-app.js"></script> | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> | |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> | |
| <style> | |
| :root { | |
| --tg-bg-color: var(--tg-theme-bg-color, #000); | |
| --tg-text-color: var(--tg-theme-text-color, #fff); | |
| --tg-hint-color: var(--tg-theme-hint-color, #aaa); | |
| --tg-button-color: var(--tg-theme-button-color, #007aff); | |
| --tg-button-text-color: var(--tg-theme-button-text-color, #fff); | |
| } | |
| body, html { | |
| margin: 0; | |
| padding: 0; | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| background-color: var(--tg-bg-color); | |
| color: var(--tg-text-color); | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| } | |
| #ar-container { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: 1; | |
| } | |
| #camera-view { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| z-index: 0; | |
| } | |
| .hotspot { | |
| position: absolute; | |
| background-color: rgba(0, 122, 255, 0.85); | |
| color: white; | |
| padding: 10px 15px; | |
| border-radius: 12px; | |
| border: 1px solid rgba(255, 255, 255, 0.5); | |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6); | |
| transform: translate(-50%, -50%); | |
| transition: opacity 0.3s, transform 0.1s linear; | |
| white-space: nowrap; | |
| font-size: 16px; | |
| font-weight: 500; | |
| will-change: transform, left, top, opacity; | |
| z-index: 10; | |
| text-align: center; | |
| backdrop-filter: blur(5px); | |
| -webkit-backdrop-filter: blur(5px); | |
| } | |
| .hotspot small { | |
| font-size: 0.75em; | |
| opacity: 0.85; | |
| display: block; | |
| margin-top: 5px; | |
| font-weight: 400; | |
| } | |
| .hotspot.hidden { | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| #map-container { | |
| position: fixed; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 220px; | |
| height: 150px; | |
| border-radius: 15px; | |
| overflow: hidden; | |
| transition: all 0.3s ease-in-out; | |
| z-index: 50; | |
| border: 1px solid var(--tg-hint-color); | |
| box-shadow: 0 5px 25px rgba(0,0,0,0.5); | |
| } | |
| #map-container.fullscreen { | |
| width: 100vw; | |
| height: 100vh; | |
| top: 0; | |
| left: 0; | |
| bottom: 0; | |
| border-radius: 0; | |
| transform: none; | |
| border: none; | |
| } | |
| #map { | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .leaflet-control-container .leaflet-control-attribution { | |
| display: none; | |
| } | |
| #welcome-message { | |
| position: fixed; | |
| top: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: rgba(0,0,0,0.6); | |
| padding: 8px 15px; | |
| border-radius: 20px; | |
| z-index: 51; | |
| font-size: 14px; | |
| backdrop-filter: blur(5px); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="welcome-message"></div> | |
| <video id="camera-view" playsinline autoplay muted></video> | |
| <div id="ar-container"></div> | |
| <div id="map-container"> | |
| <div id="map"></div> | |
| </div> | |
| <script> | |
| const tg = window.Telegram.WebApp; | |
| tg.ready(); | |
| tg.expand(); | |
| const state = { | |
| hotspots: [], | |
| currentUserPosition: null, | |
| deviceOrientation: { alpha: 0, beta: 0, gamma: 0 }, | |
| smoothedAlpha: 0, | |
| smoothingFactor: 0.05, | |
| cameraFov: 60, | |
| map: null, | |
| userMarker: null, | |
| hotspotMarkers: [], | |
| initialMapSet: false, | |
| userInfo: tg.initDataUnsafe.user || {} | |
| }; | |
| const MAX_VISIBLE_DISTANCE = 1000; | |
| function formatUserName(user) { | |
| if (!user) return "Аноним"; | |
| let name = user.first_name || ''; | |
| if (user.last_name) name += ` ${user.last_name}`; | |
| if (user.username) name += ` (@${user.username})`; | |
| return name.trim(); | |
| } | |
| document.getElementById('welcome-message').innerText = `Привет, ${state.userInfo.first_name || 'пользователь'}!`; | |
| async function initArApp() { | |
| await setupCamera(); | |
| setupGPS(); | |
| setupOrientationListener(); | |
| initMap(); | |
| await loadHotspots(); | |
| setupAddHotspotListener(); | |
| requestAnimationFrame(update); | |
| } | |
| async function setupCamera() { | |
| const video = document.getElementById('camera-view'); | |
| if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { facingMode: 'environment' } | |
| }); | |
| video.srcObject = stream; | |
| await video.play(); | |
| } catch (err) { | |
| tg.showAlert('Не удалось получить доступ к камере: ' + err.message); | |
| } | |
| } | |
| } | |
| function setupGPS() { | |
| if (navigator.geolocation) { | |
| navigator.geolocation.watchPosition( | |
| (position) => { | |
| state.currentUserPosition = { | |
| lat: position.coords.latitude, | |
| lon: position.coords.longitude | |
| }; | |
| if (state.userMarker) { | |
| state.userMarker.setLatLng([state.currentUserPosition.lat, state.currentUserPosition.lon]); | |
| if (!state.initialMapSet) { | |
| state.map.setView([state.currentUserPosition.lat, state.currentUserPosition.lon], 16); | |
| state.initialMapSet = true; | |
| } | |
| } | |
| }, | |
| (error) => { | |
| tg.showAlert('Не удалось получить доступ к GPS: ' + error.message); | |
| }, | |
| { enableHighAccuracy: true, maximumAge: 0, timeout: 5000 } | |
| ); | |
| } else { | |
| tg.showAlert('GPS не поддерживается вашим устройством.'); | |
| } | |
| } | |
| function setupOrientationListener() { | |
| const handleOrientation = (event) => { | |
| if (event.alpha !== null) { | |
| state.deviceOrientation.alpha = event.alpha; | |
| state.deviceOrientation.beta = event.beta; | |
| state.deviceOrientation.gamma = event.gamma; | |
| let alpha = event.webkitCompassHeading || event.alpha; | |
| let diff = alpha - state.smoothedAlpha; | |
| if (diff > 180) diff -= 360; | |
| if (diff < -180) diff += 360; | |
| state.smoothedAlpha += state.smoothingFactor * diff; | |
| state.smoothedAlpha = (state.smoothedAlpha + 360) % 360; | |
| } | |
| }; | |
| if (window.DeviceOrientationEvent) { | |
| if (typeof DeviceOrientationEvent.requestPermission === 'function') { | |
| DeviceOrientationEvent.requestPermission() | |
| .then(permissionState => { | |
| if (permissionState === 'granted') { | |
| window.addEventListener('deviceorientation', handleOrientation, true); | |
| } else { | |
| tg.showAlert('Доступ к ориентации устройства отклонен.'); | |
| } | |
| }) | |
| .catch(console.error); | |
| } else { | |
| window.addEventListener('deviceorientation', handleOrientation, true); | |
| } | |
| } else { | |
| tg.showAlert('Отслеживание ориентации устройства не поддерживается.'); | |
| } | |
| } | |
| function initMap() { | |
| state.map = L.map('map', { zoomControl: false }).setView([0, 0], 2); | |
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(state.map); | |
| var userIcon = L.divIcon({ | |
| className: 'user-location-dot', | |
| html: '<div style="background-color: #007aff; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white;"></div>', | |
| iconSize: [16, 16], | |
| iconAnchor: [8, 8] | |
| }); | |
| state.userMarker = L.marker([0, 0], { icon: userIcon }).addTo(state.map); | |
| document.getElementById('map-container').addEventListener('click', () => { | |
| const mapContainer = document.getElementById('map-container'); | |
| mapContainer.classList.toggle('fullscreen'); | |
| setTimeout(() => { | |
| state.map.invalidateSize(); | |
| if (state.currentUserPosition) { | |
| state.map.setView([state.currentUserPosition.lat, state.currentUserPosition.lon], state.map.getZoom()); | |
| } | |
| }, 310); | |
| }); | |
| } | |
| async function loadHotspots() { | |
| try { | |
| const response = await fetch('/hotspots'); | |
| const data = await response.json(); | |
| state.hotspots = data; | |
| renderHotspots(); | |
| renderHotspotsOnMap(); | |
| } catch (error) { | |
| tg.showAlert('Ошибка загрузки хотспотов: ' + error.message); | |
| } | |
| } | |
| function renderHotspots() { | |
| const container = document.getElementById('ar-container'); | |
| container.innerHTML = ''; | |
| state.hotspots.forEach((hotspot, index) => { | |
| const el = document.createElement('div'); | |
| el.className = 'hotspot'; | |
| el.id = `hotspot-${index}`; | |
| el.innerHTML = `${hotspot.text}<br><small>by ${hotspot.creator_info || 'Unknown'}</small>`; | |
| container.appendChild(el); | |
| }); | |
| } | |
| function renderHotspotsOnMap() { | |
| if (!state.map) return; | |
| state.hotspotMarkers.forEach(marker => state.map.removeLayer(marker)); | |
| state.hotspotMarkers = []; | |
| state.hotspots.forEach(hotspot => { | |
| const marker = L.marker([hotspot.lat, hotspot.lon]) | |
| .addTo(state.map) | |
| .bindPopup(`<b>${hotspot.text}</b><br>by ${hotspot.creator_info || 'Unknown'}`); | |
| state.hotspotMarkers.push(marker); | |
| }); | |
| } | |
| function setupAddHotspotListener() { | |
| document.body.addEventListener('dblclick', (event) => { | |
| if (event.target.closest('#map-container')) return; | |
| tg.showPopup({ | |
| title: 'Новый хотспот', | |
| message: 'Введите текст для новой AR-метки. Она будет создана в вашем текущем местоположении.', | |
| buttons: [ | |
| {id: 'create', type: 'default', text: 'Создать'}, | |
| {type: 'cancel'}, | |
| ] | |
| }, async (buttonId) => { | |
| if (buttonId === 'create') { | |
| const text = prompt("Введите текст для хотспота:"); | |
| if (text && text.trim() !== '') { | |
| await createHotspot(text.trim()); | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| async function createHotspot(text) { | |
| if (!state.currentUserPosition) { | |
| tg.showAlert('GPS-координаты еще не определены. Подождите и попробуйте снова.'); | |
| return; | |
| } | |
| const newHotspotData = { | |
| text: text, | |
| lat: state.currentUserPosition.lat, | |
| lon: state.currentUserPosition.lon, | |
| initData: tg.initData | |
| }; | |
| try { | |
| const response = await fetch('/hotspots', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(newHotspotData) | |
| }); | |
| if (response.ok) { | |
| const savedHotspot = await response.json(); | |
| state.hotspots.push(savedHotspot.hotspot); | |
| renderHotspots(); | |
| renderHotspotsOnMap(); | |
| tg.HapticFeedback.notificationOccurred('success'); | |
| } else { | |
| const error = await response.json(); | |
| tg.showAlert(`Не удалось сохранить хотспот: ${error.error}`); | |
| } | |
| } catch (error) { | |
| tg.showAlert('Сетевая ошибка при сохранении хотспота.'); | |
| } | |
| } | |
| function haversineDistance(coords1, coords2) { | |
| function toRad(x) { return x * Math.PI / 180; } | |
| const R = 6371000; | |
| const dLat = toRad(coords2.lat - coords1.lat); | |
| const dLon = toRad(coords2.lon - coords1.lon); | |
| const lat1 = toRad(coords1.lat); | |
| const lat2 = toRad(coords2.lat); | |
| const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + | |
| Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2); | |
| const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); | |
| return R * c; | |
| } | |
| function calculateBearing(start, end) { | |
| function toRad(x) { return x * Math.PI / 180; } | |
| function toDeg(x) { return x * 180 / Math.PI; } | |
| const y = Math.sin(toRad(end.lon - start.lon)) * Math.cos(toRad(end.lat)); | |
| const x = Math.cos(toRad(start.lat)) * Math.sin(toRad(end.lat)) - | |
| Math.sin(toRad(start.lat)) * Math.cos(toRad(end.lat)) * Math.cos(toRad(end.lon - start.lon)); | |
| let brng = toDeg(Math.atan2(y, x)); | |
| return (brng + 360) % 360; | |
| } | |
| function update() { | |
| if (!state.currentUserPosition) { | |
| requestAnimationFrame(update); | |
| return; | |
| } | |
| const screenWidth = window.innerWidth; | |
| const screenHeight = window.innerHeight; | |
| state.hotspots.forEach((hotspot, index) => { | |
| const el = document.getElementById(`hotspot-${index}`); | |
| if (!el) return; | |
| const distance = haversineDistance(state.currentUserPosition, hotspot); | |
| if (distance > MAX_VISIBLE_DISTANCE) { | |
| el.classList.add('hidden'); | |
| return; | |
| } | |
| const bearing = calculateBearing(state.currentUserPosition, hotspot); | |
| let angleDiff = bearing - state.smoothedAlpha; | |
| if (angleDiff > 180) angleDiff -= 360; | |
| if (angleDiff < -180) angleDiff += 360; | |
| if (Math.abs(angleDiff) > state.cameraFov / 2) { | |
| el.classList.add('hidden'); | |
| } else { | |
| el.classList.remove('hidden'); | |
| const x = screenWidth / 2 + (angleDiff / (state.cameraFov / 2)) * (screenWidth / 2); | |
| const verticalAngle = -state.deviceOrientation.beta; | |
| const y = screenHeight / 2 - (Math.tan(verticalAngle * Math.PI / 180) * (screenHeight / 2)); | |
| const scale = Math.max(0.4, 1.2 - distance / MAX_VISIBLE_DISTANCE); | |
| el.style.left = `${x}px`; | |
| el.style.top = `${y}px`; | |
| el.style.transform = `translate(-50%, -50%) scale(${scale})`; | |
| el.style.zIndex = Math.round(10000 - distance); | |
| } | |
| }); | |
| requestAnimationFrame(update); | |
| } | |
| initArApp(); | |
| </script> | |
| </body> | |
| </html> | |
| ''' | |
| return Response(html_content, mimetype='text/html') | |
| def handle_hotspots(): | |
| if request.method == 'GET': | |
| return jsonify(get_all_hotspots()) | |
| if request.method == 'POST': | |
| if not request.is_json: | |
| return jsonify({"error": "Missing JSON in request"}), 400 | |
| data = request.get_json() | |
| init_data = data.get('initData') | |
| if not init_data: | |
| return jsonify({"error": "Missing initData"}), 401 | |
| is_safe, user_data = is_data_safe(init_data) | |
| if not is_safe: | |
| return jsonify({"error": "Validation failed"}), 403 | |
| text = data.get('text') | |
| lat = data.get('lat') | |
| lon = data.get('lon') | |
| if not all([text, lat, lon]): | |
| return jsonify({"error": "Missing data: text, lat, or lon"}), 400 | |
| try: | |
| creator_info = user_data.get('first_name', 'User') | |
| if user_data.get('username'): | |
| creator_info = f"{creator_info} (@{user_data.get('username')})" | |
| new_hotspot = { | |
| "text": str(text), | |
| "lat": float(lat), | |
| "lon": float(lon), | |
| "creator_info": creator_info, | |
| "creator_id": user_data.get('id') | |
| } | |
| save_hotspot(new_hotspot) | |
| return jsonify({"success": True, "hotspot": new_hotspot}), 201 | |
| except (ValueError, TypeError): | |
| return jsonify({"error": "Invalid data types"}), 400 | |
| if __name__ == '__main__': | |
| app.run(host='0.0.0.0', port=7860, debug=False) |