Aleksmorshen commited on
Commit
f756f2c
·
verified ·
1 Parent(s): a6dadd9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +287 -277
app.py CHANGED
@@ -1,5 +1,5 @@
 
1
  from flask import Flask, Response, request, jsonify
2
- import requests
3
  import json
4
  import os
5
  import hmac
@@ -33,19 +33,19 @@ def index():
33
  <html lang="ru">
34
  <head>
35
  <meta charset="UTF-8">
36
- <title>TG AR Hotspots</title>
37
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
38
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
39
  <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
40
  <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
41
  <style>
42
  :root {
43
- --tg-bg-color: var(--tg-theme-bg-color, #000);
44
- --tg-text-color: var(--tg-theme-text-color, #fff);
 
45
  --tg-button-color: var(--tg-theme-button-color, #007aff);
46
- --tg-button-text-color: var(--tg-theme-button-text-color, #fff);
47
- --tg-hint-color: var(--tg-theme-hint-color, #aaa);
48
- --tg-secondary-bg-color: var(--tg-theme-secondary-bg-color, #1c1c1e);
49
  }
50
  body, html {
51
  margin: 0;
@@ -57,30 +57,6 @@ def index():
57
  color: var(--tg-text-color);
58
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
59
  }
60
- #auth-wall {
61
- position: fixed;
62
- top: 0;
63
- left: 0;
64
- width: 100%;
65
- height: 100%;
66
- background-color: var(--tg-bg-color);
67
- z-index: 200;
68
- display: flex;
69
- flex-direction: column;
70
- align-items: center;
71
- justify-content: center;
72
- transition: opacity 0.5s ease;
73
- text-align: center;
74
- }
75
- #auth-wall.hidden {
76
- opacity: 0;
77
- pointer-events: none;
78
- }
79
- #main-content {
80
- display: none;
81
- width: 100%;
82
- height: 100%;
83
- }
84
  #ar-container {
85
  position: absolute;
86
  top: 0;
@@ -100,29 +76,24 @@ def index():
100
  }
101
  .hotspot {
102
  position: absolute;
103
- background-color: rgba(0, 122, 255, 0.9);
104
- color: white;
105
  padding: 10px 15px;
106
- border-radius: 12px;
107
- border: 1px solid rgba(255, 255, 255, 0.3);
108
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
109
  transform: translate(-50%, -50%);
110
  transition: opacity 0.3s, transform 0.1s linear;
111
  white-space: nowrap;
112
  font-size: 16px;
113
- font-weight: 500;
114
  will-change: transform, left, top, opacity;
115
  z-index: 10;
116
  text-align: center;
117
- backdrop-filter: blur(10px);
118
- -webkit-backdrop-filter: blur(10px);
119
  }
120
  .hotspot small {
121
- font-size: 0.75em;
122
  opacity: 0.8;
123
  display: block;
124
  margin-top: 5px;
125
- font-weight: 400;
126
  }
127
  .hotspot.hidden {
128
  opacity: 0;
@@ -130,20 +101,18 @@ def index():
130
  }
131
  #map-container {
132
  position: fixed;
133
- bottom: 20px;
134
- left: 50%;
135
- transform: translateX(-50%);
136
- width: 200px;
137
- height: 200px;
138
- background-color: var(--tg-secondary-bg-color);
139
- border-radius: 15px;
140
  overflow: hidden;
141
- transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
142
  z-index: 50;
143
  display: flex;
144
  flex-direction: column;
145
  border: 1px solid var(--tg-hint-color);
146
- box-shadow: 0 8px 32px rgba(0,0,0,0.3);
147
  }
148
  #map-container.fullscreen {
149
  width: 100%;
@@ -152,54 +121,149 @@ def index():
152
  left: 0;
153
  bottom: 0;
154
  border-radius: 0;
155
- transform: none;
156
- border: none;
157
  }
158
  #map {
159
  flex-grow: 1;
160
  width: 100%;
161
- height: 100%;
 
 
 
 
 
 
 
 
 
 
 
162
  }
163
- #map-controls {
164
  position: absolute;
165
  top: 10px;
166
  right: 10px;
167
- z-index: 1000;
 
 
168
  }
169
- #toggle-map-button {
 
 
 
 
 
170
  background-color: var(--tg-button-color);
171
  color: var(--tg-button-text-color);
 
172
  border: none;
173
- padding: 8px 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  border-radius: 8px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  cursor: pointer;
176
- font-size: 14px;
177
- box-shadow: 0 2px 5px rgba(0,0,0,0.2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  }
179
  </style>
180
  </head>
181
  <body>
182
- <div id="auth-wall">
183
- <h1>AR Хотспоты</h1>
184
- <p>Авторизация через Telegram...</p>
185
- </div>
186
 
187
- <div id="main-content">
188
- <video id="camera-view" playsinline autoplay muted></video>
189
- <div id="ar-container"></div>
190
- <div id="map-container">
191
- <div id="map"></div>
192
- <div id="map-controls">
193
- <button id="toggle-map-button">Карта</button>
 
 
 
 
 
 
 
194
  </div>
195
  </div>
196
  </div>
197
 
198
  <script>
 
 
 
 
199
  const state = {
200
  hotspots: [],
201
- currentUser: null,
202
  currentUserPosition: null,
 
203
  deviceOrientation: { alpha: 0, beta: 0, gamma: 0 },
204
  smoothedOrientation: { alpha: 0, beta: 0, gamma: 0 },
205
  cameraFov: 60,
@@ -207,49 +271,29 @@ def index():
207
  userMarker: null,
208
  hotspotMarkers: [],
209
  initialMapSet: false,
210
- isMapFullscreen: false,
211
  };
212
 
213
- const SMOOTHING_FACTOR = 0.05;
214
- const MAX_VISIBLE_DISTANCE = 2000;
215
-
216
- window.addEventListener('load', function() {
217
- const tg = window.Telegram.WebApp;
218
- tg.ready();
219
- tg.expand();
220
- authorizeUser(tg.initData);
221
- });
222
 
223
- async function authorizeUser(initData) {
224
- if (!initData) {
225
- document.getElementById('auth-wall').innerHTML = '<h1>Ошибка</h1><p>Не удалось получить данные Telegram. Пожалуйста, откройте приложение через Telegram.</p>';
226
- return;
227
- }
228
-
229
- try {
230
- const response = await fetch('/validate_init_data', {
231
- method: 'POST',
232
- headers: { 'Content-Type': 'application/json' },
233
- body: JSON.stringify({ initData: initData })
234
- });
235
 
236
- if (response.ok) {
237
- const userData = await response.json();
238
- state.currentUser = userData;
239
- initArApp();
240
- } else {
241
- throw new Error('Validation failed');
242
- }
243
- } catch (error) {
244
- document.getElementById('auth-wall').innerHTML = '<h1>Ошибка</h1><p>Не удалось авторизоваться. Попробуйте перезапустить приложение.</p>';
245
- console.error('Authorization error:', error);
246
- }
247
  }
248
 
249
  async function initArApp() {
250
- document.getElementById('auth-wall').classList.add('hidden');
251
- document.getElementById('main-content').style.display = 'block';
252
-
253
  await setupCamera();
254
  setupGPS();
255
  setupOrientationListener();
@@ -259,6 +303,30 @@ def index():
259
  requestAnimationFrame(update);
260
  }
261
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  async function setupCamera() {
263
  const video = document.getElementById('camera-view');
264
  if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
@@ -269,7 +337,7 @@ def index():
269
  video.srcObject = stream;
270
  await video.play();
271
  } catch (err) {
272
- alert('Не удалось получить доступ к камере: ' + err);
273
  }
274
  }
275
  }
@@ -282,78 +350,66 @@ def index():
282
  lat: position.coords.latitude,
283
  lon: position.coords.longitude
284
  };
 
 
 
 
 
 
285
  if (state.userMarker) {
286
- state.userMarker.setLatLng([state.currentUserPosition.lat, state.currentUserPosition.lon]);
287
  if (!state.initialMapSet) {
288
- state.map.setView([state.currentUserPosition.lat, state.currentUserPosition.lon], 16);
289
  state.initialMapSet = true;
290
  }
291
  }
292
  },
293
  (error) => {
294
- alert('Не удалось получить доступ к GPS: ' + error.message);
295
  },
296
  { enableHighAccuracy: true, maximumAge: 1000, timeout: 5000 }
297
  );
298
  } else {
299
- alert('GPS не поддерживается вашим браузером.');
300
  }
301
  }
302
 
303
  function setupOrientationListener() {
304
  if (window.DeviceOrientationEvent) {
305
  window.addEventListener('deviceorientationabsolute', (event) => {
306
- if (event.alpha !== null) {
307
  state.deviceOrientation.alpha = event.alpha;
308
  state.deviceOrientation.beta = event.beta;
309
  state.deviceOrientation.gamma = event.gamma;
 
 
 
 
310
  }
311
  }, true);
312
  } else {
313
- alert('Отслеживание ориентации устройства не поддерживается.');
314
  }
315
  }
316
 
317
  function initMap() {
318
- state.map = L.map('map', { zoomControl: false }).setView([0, 0], 2);
319
  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
320
  attribution: '© OpenStreetMap'
321
  }).addTo(state.map);
322
-
323
- const userIcon = L.divIcon({
324
- html: '<div style="background-color: #007aff; width: 16px; height: 16px; border-radius: 50%; border: 3px solid white; box-shadow: 0 0 10px #007aff;"></div>',
325
- className: '',
326
- iconSize: [22, 22],
327
- iconAnchor: [11, 11]
328
- });
329
- state.userMarker = L.marker([0, 0], {icon: userIcon}).addTo(state.map).bindPopup('Это вы');
330
-
331
  document.getElementById('toggle-map-button').addEventListener('click', toggleMap);
332
  }
333
 
334
  function toggleMap() {
335
- window.Telegram.WebApp.HapticFeedback.impactOccurred('light');
336
  const mapContainer = document.getElementById('map-container');
337
- const btn = document.getElementById('toggle-map-button');
338
- state.isMapFullscreen = !state.isMapFullscreen;
339
-
340
- mapContainer.classList.toggle('fullscreen', state.isMapFullscreen);
341
- btn.innerText = state.isMapFullscreen ? 'Свернуть' : 'Карта';
342
-
343
- if (state.isMapFullscreen) {
344
- document.getElementById('ar-container').style.display = 'none';
345
- document.getElementById('camera-view').style.display = 'none';
346
- } else {
347
- document.getElementById('ar-container').style.display = 'block';
348
- document.getElementById('camera-view').style.display = 'block';
349
- }
350
-
351
  setTimeout(() => {
352
  state.map.invalidateSize();
353
- if (state.currentUserPosition) {
354
- state.map.setView([state.currentUserPosition.lat, state.currentUserPosition.lon], state.map.getZoom());
355
  }
356
- }, 400);
357
  }
358
 
359
  async function loadHotspots() {
@@ -373,9 +429,9 @@ def index():
373
  container.innerHTML = '';
374
  state.hotspots.forEach((hotspot, index) => {
375
  const el = document.createElement('div');
376
- el.className = 'hotspot hidden';
377
  el.id = `hotspot-${index}`;
378
- el.innerHTML = `${hotspot.text}<br><small>@${hotspot.creator_username || 'unknown'}</small>`;
379
  container.appendChild(el);
380
  });
381
  }
@@ -384,102 +440,79 @@ def index():
384
  if (!state.map) return;
385
  state.hotspotMarkers.forEach(marker => state.map.removeLayer(marker));
386
  state.hotspotMarkers = [];
 
387
  state.hotspots.forEach(hotspot => {
388
  const marker = L.marker([hotspot.lat, hotspot.lon])
389
  .addTo(state.map)
390
- .bindPopup(`<b>${hotspot.text}</b><br>от @${hotspot.creator_username || 'unknown'}`);
391
  state.hotspotMarkers.push(marker);
392
  });
393
  }
394
-
395
  function setupAddHotspotListener() {
396
- const container = document.getElementById('ar-container');
397
- container.addEventListener('click', (event) => {
398
- if (event.target !== container) return;
399
 
400
- window.Telegram.WebApp.HapticFeedback.impactOccurred('medium');
401
-
402
- if (!state.currentUserPosition) {
403
- window.Telegram.WebApp.showAlert('GPS-координаты еще не определены. Подождите немного.');
404
  return;
405
  }
 
 
 
 
406
 
407
- window.Telegram.WebApp.showPopup({
408
- title: 'Новый хотспот',
409
- message: 'Введите текст для новой AR-метки:',
410
- buttons: [{id: 'send', type: 'default', text: 'Создать'}, {type: 'cancel'}]
411
- }, async (buttonId) => {
412
- if (buttonId === 'send') {
413
- const text = window.Telegram.WebApp.Popup.text;
414
- if (text) {
415
- const newHotspot = {
416
- text: text,
417
- lat: state.currentUserPosition.lat,
418
- lon: state.currentUserPosition.lon,
419
- creator_id: state.currentUser.id,
420
- creator_username: state.currentUser.username
421
- };
422
-
423
- try {
424
- const response = await fetch('/hotspots', {
425
- method: 'POST',
426
- headers: { 'Content-Type': 'application/json' },
427
- body: JSON.stringify(newHotspot)
428
- });
429
- if(response.ok) {
430
- const savedHotspot = await response.json();
431
- state.hotspots.push(savedHotspot.hotspot);
432
- renderHotspots();
433
- renderHotspotsOnMap();
434
- window.Telegram.WebApp.HapticFeedback.notificationOccurred('success');
435
- } else {
436
- window.Telegram.WebApp.showAlert('Не удалось сохранить хотспот.');
437
- }
438
- } catch (error) {
439
- console.error('Ошибка сохранения хотспота:', error);
440
- window.Telegram.WebApp.showAlert('Ошибка сети при сохранении хотспота.');
441
- }
442
- }
443
- }
444
- });
445
- // This is a workaround to make the popup appear with a text input field
446
- setTimeout(() => {
447
- const popup = document.querySelector('.popup-body');
448
- if (popup) {
449
- const input = document.createElement('input');
450
- input.type = 'text';
451
- input.className = 'popup-input';
452
- input.placeholder = 'Ваш текст здесь...';
453
- input.style.width = '90%';
454
- input.style.padding = '10px';
455
- input.style.margin = '10px auto';
456
- input.style.border = '1px solid var(--tg-theme-hint-color)';
457
- input.style.borderRadius = '5px';
458
- input.style.backgroundColor = 'var(--tg-theme-bg-color)';
459
- input.style.color = 'var(--tg-theme-text-color)';
460
-
461
- popup.insertBefore(input, popup.firstChild);
462
- input.focus();
463
-
464
- Object.defineProperty(window.Telegram.WebApp.Popup, 'text', {
465
- get: () => input.value,
466
- configurable: true
467
  });
 
 
 
 
 
 
 
 
 
 
 
 
468
  }
469
- }, 50);
470
  });
471
  }
472
 
473
  function haversineDistance(coords1, coords2) {
474
  function toRad(x) { return x * Math.PI / 180; }
475
- const R = 6371e3;
476
  const dLat = toRad(coords2.lat - coords1.lat);
477
  const dLon = toRad(coords2.lon - coords1.lon);
 
 
478
  const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
479
- Math.cos(toRad(coords1.lat)) * Math.cos(toRad(coords2.lat)) *
480
- Math.sin(dLon / 2) * Math.sin(dLon / 2);
481
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
482
- return R * c;
483
  }
484
 
485
  function calculateBearing(start, end) {
@@ -491,30 +524,16 @@ def index():
491
  const lon2 = toRad(end.lon);
492
  const y = Math.sin(lon2 - lon1) * Math.cos(lat2);
493
  const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1);
494
- return (toDeg(Math.atan2(y, x)) + 360) % 360;
495
- }
496
-
497
- function circularLerp(start, end, amount) {
498
- let difference = Math.abs(end - start);
499
- if (difference > 180) {
500
- if (end > start) {
501
- start += 360;
502
- } else {
503
- end += 360;
504
- }
505
- }
506
- let value = (start + ((end - start) * amount));
507
- return value % 360;
508
  }
509
 
510
  function update() {
511
- if (!state.currentUserPosition || state.isMapFullscreen) {
512
  requestAnimationFrame(update);
513
  return;
514
  }
515
 
516
- state.smoothedOrientation.alpha = circularLerp(state.smoothedOrientation.alpha, state.deviceOrientation.alpha, SMOOTHING_FACTOR);
517
-
518
  const screenWidth = window.innerWidth;
519
  const screenHeight = window.innerHeight;
520
 
@@ -522,33 +541,32 @@ def index():
522
  const el = document.getElementById(`hotspot-${index}`);
523
  if (!el) return;
524
 
525
- const distance = haversineDistance(state.currentUserPosition, hotspot);
526
 
527
  if (distance > MAX_VISIBLE_DISTANCE) {
528
  el.classList.add('hidden');
529
  return;
530
  }
531
 
532
- const bearing = calculateBearing(state.currentUserPosition, hotspot);
533
  let angleDiff = bearing - state.smoothedOrientation.alpha;
534
 
535
  if (angleDiff > 180) angleDiff -= 360;
536
  if (angleDiff < -180) angleDiff += 360;
537
-
538
  if (Math.abs(angleDiff) > state.cameraFov / 2) {
539
  el.classList.add('hidden');
540
  } else {
541
  el.classList.remove('hidden');
542
 
543
  const x = screenWidth / 2 + (angleDiff / (state.cameraFov / 2)) * (screenWidth / 2);
544
- const y = screenHeight / 2 - 50;
545
-
546
- const scale = Math.max(0.5, 1.2 - (distance / MAX_VISIBLE_DISTANCE));
547
 
548
  el.style.left = `${x}px`;
549
  el.style.top = `${y}px`;
550
  el.style.transform = `translate(-50%, -50%) scale(${scale})`;
551
- el.style.zIndex = Math.round(10000 - distance);
552
  }
553
  });
554
 
@@ -560,41 +578,33 @@ def index():
560
  '''
561
  return Response(html_content, mimetype='text/html')
562
 
563
- @app.route('/validate_init_data', methods=['POST'])
564
- def validate_init_data():
565
  data = request.get_json()
566
- init_data = data.get('initData')
 
 
567
 
568
- if not init_data:
569
- return jsonify({'error': 'No initData provided'}), 400
 
 
 
570
 
571
- try:
572
- parsed_data = urllib.parse.parse_qsl(init_data)
573
- data_dict = dict(parsed_data)
574
-
575
- received_hash = data_dict.pop('hash', None)
576
- if not received_hash:
577
- return jsonify({'error': 'Hash not found in initData'}), 400
578
 
579
- data_check_string = "\n".join(sorted([f"{k}={v}" for k, v in data_dict.items()]))
580
-
581
- secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest()
582
- calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
583
-
584
- if calculated_hash == received_hash:
585
- user_data = json.loads(data_dict.get('user', '{}'))
586
- return jsonify({
587
- 'id': user_data.get('id'),
588
- 'first_name': user_data.get('first_name'),
589
- 'last_name': user_data.get('last_name'),
590
- 'username': user_data.get('username'),
591
- 'language_code': user_data.get('language_code'),
592
- }), 200
593
- else:
594
- return jsonify({'error': 'Invalid hash'}), 403
595
-
596
- except Exception as e:
597
- return jsonify({'error': str(e)}), 500
598
 
599
  @app.route('/hotspots', methods=['GET', 'POST'])
600
  def handle_hotspots():
@@ -610,10 +620,10 @@ def handle_hotspots():
610
  lat = data.get('lat')
611
  lon = data.get('lon')
612
  creator_id = data.get('creator_id')
613
- creator_username = data.get('creator_username')
614
 
615
- if not all([text, lat, lon, creator_id]):
616
- return jsonify({"error": "Missing data"}), 400
617
 
618
  try:
619
  new_hotspot = {
@@ -621,7 +631,7 @@ def handle_hotspots():
621
  "lat": float(lat),
622
  "lon": float(lon),
623
  "creator_id": int(creator_id),
624
- "creator_username": str(creator_username) if creator_username else None
625
  }
626
  save_hotspot(new_hotspot)
627
  return jsonify({"success": True, "hotspot": new_hotspot}), 201
 
1
+ import flask
2
  from flask import Flask, Response, request, jsonify
 
3
  import json
4
  import os
5
  import hmac
 
33
  <html lang="ru">
34
  <head>
35
  <meta charset="UTF-8">
36
+ <title>TON AR Hotspots</title>
37
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
38
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
39
  <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
40
  <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
41
  <style>
42
  :root {
43
+ --tg-bg-color: var(--tg-theme-bg-color, #ffffff);
44
+ --tg-text-color: var(--tg-theme-text-color, #000000);
45
+ --tg-hint-color: var(--tg-theme-hint-color, #999999);
46
  --tg-button-color: var(--tg-theme-button-color, #007aff);
47
+ --tg-button-text-color: var(--tg-theme-button-text-color, #ffffff);
48
+ --tg-secondary-bg-color: var(--tg-theme-secondary-bg-color, #f0f0f0);
 
49
  }
50
  body, html {
51
  margin: 0;
 
57
  color: var(--tg-text-color);
58
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
59
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  #ar-container {
61
  position: absolute;
62
  top: 0;
 
76
  }
77
  .hotspot {
78
  position: absolute;
79
+ background-color: var(--tg-button-color);
80
+ color: var(--tg-button-text-color);
81
  padding: 10px 15px;
82
+ border-radius: 10px;
83
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
 
84
  transform: translate(-50%, -50%);
85
  transition: opacity 0.3s, transform 0.1s linear;
86
  white-space: nowrap;
87
  font-size: 16px;
 
88
  will-change: transform, left, top, opacity;
89
  z-index: 10;
90
  text-align: center;
 
 
91
  }
92
  .hotspot small {
93
+ font-size: 0.7em;
94
  opacity: 0.8;
95
  display: block;
96
  margin-top: 5px;
 
97
  }
98
  .hotspot.hidden {
99
  opacity: 0;
 
101
  }
102
  #map-container {
103
  position: fixed;
104
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 80px);
105
+ left: 10px;
106
+ width: 150px;
107
+ height: 150px;
108
+ background-color: rgba(0, 0, 0, 0.7);
109
+ border-radius: 10px;
 
110
  overflow: hidden;
111
+ transition: all 0.3s ease;
112
  z-index: 50;
113
  display: flex;
114
  flex-direction: column;
115
  border: 1px solid var(--tg-hint-color);
 
116
  }
117
  #map-container.fullscreen {
118
  width: 100%;
 
121
  left: 0;
122
  bottom: 0;
123
  border-radius: 0;
124
+ justify-content: center;
125
+ align-items: center;
126
  }
127
  #map {
128
  flex-grow: 1;
129
  width: 100%;
130
+ height: calc(100% - 30px);
131
+ }
132
+ #map-container.fullscreen #map { height: 100%; }
133
+ #toggle-map-button {
134
+ background-color: rgba(0, 0, 0, 0.5);
135
+ color: white;
136
+ border: none;
137
+ padding: 5px;
138
+ cursor: pointer;
139
+ font-size: 12px;
140
+ align-self: center;
141
+ width: 100%;
142
  }
143
+ #map-container.fullscreen #toggle-map-button {
144
  position: absolute;
145
  top: 10px;
146
  right: 10px;
147
+ font-size: 16px;
148
+ width: auto;
149
+ border-radius: 5px;
150
  }
151
+ #add-hotspot-button {
152
+ position: fixed;
153
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 20px);
154
+ right: 20px;
155
+ width: 60px;
156
+ height: 60px;
157
  background-color: var(--tg-button-color);
158
  color: var(--tg-button-text-color);
159
+ border-radius: 50%;
160
  border: none;
161
+ font-size: 36px;
162
+ line-height: 60px;
163
+ text-align: center;
164
+ box-shadow: 0 4px 10px rgba(0,0,0,0.25);
165
+ cursor: pointer;
166
+ z-index: 100;
167
+ }
168
+ #modal-overlay {
169
+ position: fixed;
170
+ top: 0;
171
+ left: 0;
172
+ width: 100%;
173
+ height: 100%;
174
+ background: rgba(0,0,0,0.5);
175
+ z-index: 1000;
176
+ display: none;
177
+ align-items: center;
178
+ justify-content: center;
179
+ }
180
+ #modal-content {
181
+ background: var(--tg-secondary-bg-color);
182
+ padding: 20px;
183
+ border-radius: 12px;
184
+ width: 90%;
185
+ max-width: 400px;
186
+ box-shadow: 0 5px 15px rgba(0,0,0,0.3);
187
+ }
188
+ #modal-content h3 {
189
+ margin-top: 0;
190
+ color: var(--tg-text-color);
191
+ }
192
+ #modal-input {
193
+ width: calc(100% - 20px);
194
+ padding: 10px;
195
+ border: 1px solid var(--tg-hint-color);
196
  border-radius: 8px;
197
+ font-size: 16px;
198
+ margin-bottom: 15px;
199
+ background-color: var(--tg-bg-color);
200
+ color: var(--tg-text-color);
201
+ }
202
+ #modal-buttons {
203
+ display: flex;
204
+ justify-content: space-between;
205
+ }
206
+ .modal-btn {
207
+ width: 48%;
208
+ padding: 12px;
209
+ border: none;
210
+ border-radius: 8px;
211
+ font-size: 16px;
212
  cursor: pointer;
213
+ }
214
+ #modal-save {
215
+ background: var(--tg-button-color);
216
+ color: var(--tg-button-text-color);
217
+ }
218
+ #modal-cancel {
219
+ background: var(--tg-hint-color);
220
+ color: var(--tg-text-color);
221
+ }
222
+ .loading-indicator {
223
+ position: fixed;
224
+ top: 0; left: 0; width: 100%; height: 100%;
225
+ background-color: var(--tg-bg-color);
226
+ z-index: 9999;
227
+ display: flex;
228
+ align-items: center;
229
+ justify-content: center;
230
+ font-size: 18px;
231
  }
232
  </style>
233
  </head>
234
  <body>
235
+ <div id="loading-indicator">Авторизация...</div>
236
+
237
+ <video id="camera-view" playsinline autoplay muted></video>
238
+ <div id="ar-container"></div>
239
 
240
+ <div id="map-container">
241
+ <div id="map"></div>
242
+ <button id="toggle-map-button">Карта</button>
243
+ </div>
244
+
245
+ <button id="add-hotspot-button">+</button>
246
+
247
+ <div id="modal-overlay">
248
+ <div id="modal-content">
249
+ <h3>Новый хотспот</h3>
250
+ <input type="text" id="modal-input" placeholder="Введите текст...">
251
+ <div id="modal-buttons">
252
+ <button id="modal-cancel" class="modal-btn">Отмена</button>
253
+ <button id="modal-save" class="modal-btn">Сохранить</button>
254
  </div>
255
  </div>
256
  </div>
257
 
258
  <script>
259
+ constWebApp = window.Telegram.WebApp;
260
+ WebApp.ready();
261
+ WebApp.expand();
262
+
263
  const state = {
264
  hotspots: [],
 
265
  currentUserPosition: null,
266
+ smoothedUserPosition: null,
267
  deviceOrientation: { alpha: 0, beta: 0, gamma: 0 },
268
  smoothedOrientation: { alpha: 0, beta: 0, gamma: 0 },
269
  cameraFov: 60,
 
271
  userMarker: null,
272
  hotspotMarkers: [],
273
  initialMapSet: false,
274
+ currentUser: null,
275
  };
276
 
277
+ const MAX_VISIBLE_DISTANCE = 10000;
278
+ const GPS_SMOOTHING_FACTOR = 0.1;
279
+ const ORIENTATION_SMOOTHING_FACTOR = 0.1;
 
 
 
 
 
 
280
 
281
+ function applyEMA(current, previous, factor) {
282
+ if (previous === null) return current;
283
+ return factor * current + (1 - factor) * previous;
284
+ }
 
 
 
 
 
 
 
 
285
 
286
+ function applyAngleEMA(current, previous, factor) {
287
+ if (previous === null) return current;
288
+ let diff = current - previous;
289
+ if (diff > 180) diff -= 360;
290
+ if (diff < -180) diff += 360;
291
+ let newAngle = previous + factor * diff;
292
+ return (newAngle + 360) % 360;
 
 
 
 
293
  }
294
 
295
  async function initArApp() {
296
+ document.getElementById('loading-indicator').style.display = 'none';
 
 
297
  await setupCamera();
298
  setupGPS();
299
  setupOrientationListener();
 
303
  requestAnimationFrame(update);
304
  }
305
 
306
+ async function authenticate() {
307
+ if (!WebApp.initData) {
308
+ document.getElementById('loading-indicator').innerText = 'Ошибка: не Telegram-контекст';
309
+ return;
310
+ }
311
+ try {
312
+ const res = await fetch('/validate_auth', {
313
+ method: 'POST',
314
+ headers: { 'Content-Type': 'application/json' },
315
+ body: JSON.stringify({ initData: WebApp.initData })
316
+ });
317
+ if (!res.ok) {
318
+ throw new Error(`Auth failed: ${res.statusText}`);
319
+ }
320
+ const user = await res.json();
321
+ state.currentUser = user;
322
+ initArApp();
323
+ } catch (e) {
324
+ document.getElementById('loading-indicator').innerText = `Ошибка авторизации: ${e.message}`;
325
+ }
326
+ }
327
+
328
+ authenticate();
329
+
330
  async function setupCamera() {
331
  const video = document.getElementById('camera-view');
332
  if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
 
337
  video.srcObject = stream;
338
  await video.play();
339
  } catch (err) {
340
+ WebApp.showAlert('Не удалось получить доступ к камере: ' + err);
341
  }
342
  }
343
  }
 
350
  lat: position.coords.latitude,
351
  lon: position.coords.longitude
352
  };
353
+
354
+ const prevSmoothed = state.smoothedUserPosition;
355
+ const newLat = applyEMA(state.currentUserPosition.lat, prevSmoothed ? prevSmoothed.lat : null, GPS_SMOOTHING_FACTOR);
356
+ const newLon = applyEMA(state.currentUserPosition.lon, prevSmoothed ? prevSmoothed.lon : null, GPS_SMOOTHING_FACTOR);
357
+ state.smoothedUserPosition = { lat: newLat, lon: newLon };
358
+
359
  if (state.userMarker) {
360
+ state.userMarker.setLatLng([newLat, newLon]);
361
  if (!state.initialMapSet) {
362
+ state.map.setView([newLat, newLon], 15);
363
  state.initialMapSet = true;
364
  }
365
  }
366
  },
367
  (error) => {
368
+ WebApp.showAlert('Не удалось получить доступ к GPS: ' + error.message);
369
  },
370
  { enableHighAccuracy: true, maximumAge: 1000, timeout: 5000 }
371
  );
372
  } else {
373
+ WebApp.showAlert('GPS не поддерживается вашим браузером.');
374
  }
375
  }
376
 
377
  function setupOrientationListener() {
378
  if (window.DeviceOrientationEvent) {
379
  window.addEventListener('deviceorientationabsolute', (event) => {
380
+ if (event.alpha !== null) {
381
  state.deviceOrientation.alpha = event.alpha;
382
  state.deviceOrientation.beta = event.beta;
383
  state.deviceOrientation.gamma = event.gamma;
384
+
385
+ state.smoothedOrientation.alpha = applyAngleEMA(event.alpha, state.smoothedOrientation.alpha, ORIENTATION_SMOOTHING_FACTOR);
386
+ state.smoothedOrientation.beta = applyEMA(event.beta, state.smoothedOrientation.beta, ORIENTATION_SMOOTHING_FACTOR);
387
+ state.smoothedOrientation.gamma = applyEMA(event.gamma, state.smoothedOrientation.gamma, ORIENTATION_SMOOTHING_FACTOR);
388
  }
389
  }, true);
390
  } else {
391
+ WebApp.showAlert('Отслеживание ориентации устройства не поддерживается.');
392
  }
393
  }
394
 
395
  function initMap() {
396
+ state.map = L.map('map').setView([0, 0], 2);
397
  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
398
  attribution: '© OpenStreetMap'
399
  }).addTo(state.map);
400
+ state.userMarker = L.marker([0, 0]).addTo(state.map).bindPopup('Вы здесь');
 
 
 
 
 
 
 
 
401
  document.getElementById('toggle-map-button').addEventListener('click', toggleMap);
402
  }
403
 
404
  function toggleMap() {
 
405
  const mapContainer = document.getElementById('map-container');
406
+ mapContainer.classList.toggle('fullscreen');
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  setTimeout(() => {
408
  state.map.invalidateSize();
409
+ if (state.smoothedUserPosition) {
410
+ state.map.setView([state.smoothedUserPosition.lat, state.smoothedUserPosition.lon], state.map.getZoom());
411
  }
412
+ }, 300);
413
  }
414
 
415
  async function loadHotspots() {
 
429
  container.innerHTML = '';
430
  state.hotspots.forEach((hotspot, index) => {
431
  const el = document.createElement('div');
432
+ el.className = 'hotspot';
433
  el.id = `hotspot-${index}`;
434
+ el.innerHTML = `${hotspot.text}<br><small>by ${hotspot.creator_name || 'Unknown'}</small>`;
435
  container.appendChild(el);
436
  });
437
  }
 
440
  if (!state.map) return;
441
  state.hotspotMarkers.forEach(marker => state.map.removeLayer(marker));
442
  state.hotspotMarkers = [];
443
+
444
  state.hotspots.forEach(hotspot => {
445
  const marker = L.marker([hotspot.lat, hotspot.lon])
446
  .addTo(state.map)
447
+ .bindPopup(`${hotspot.text}<br>by ${hotspot.creator_name || 'Unknown'}`);
448
  state.hotspotMarkers.push(marker);
449
  });
450
  }
451
+
452
  function setupAddHotspotListener() {
453
+ const modalOverlay = document.getElementById('modal-overlay');
454
+ const modalInput = document.getElementById('modal-input');
 
455
 
456
+ document.getElementById('add-hotspot-button').addEventListener('click', () => {
457
+ if (!state.smoothedUserPosition) {
458
+ WebApp.showAlert('GPS-координаты еще не определены. Подождите немного.');
 
459
  return;
460
  }
461
+ modalInput.value = '';
462
+ modalOverlay.style.display = 'flex';
463
+ modalInput.focus();
464
+ });
465
 
466
+ document.getElementById('modal-cancel').addEventListener('click', () => {
467
+ modalOverlay.style.display = 'none';
468
+ });
469
+
470
+ document.getElementById('modal-save').addEventListener('click', async () => {
471
+ const text = modalInput.value.trim();
472
+ if (text) {
473
+ modalOverlay.style.display = 'none';
474
+ const newHotspot = {
475
+ text: text,
476
+ lat: state.smoothedUserPosition.lat,
477
+ lon: state.smoothedUserPosition.lon,
478
+ creator_id: state.currentUser.id,
479
+ creator_name: state.currentUser.first_name
480
+ };
481
+
482
+ try {
483
+ const response = await fetch('/hotspots', {
484
+ method: 'POST',
485
+ headers: { 'Content-Type': 'application/json' },
486
+ body: JSON.stringify(newHotspot)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  });
488
+ if(response.ok) {
489
+ const savedHotspot = await response.json();
490
+ state.hotspots.push(savedHotspot.hotspot);
491
+ renderHotspots();
492
+ renderHotspotsOnMap();
493
+ WebApp.HapticFeedback.notificationOccurred('success');
494
+ } else {
495
+ WebApp.showAlert('Не удалось сохранить хотспот.');
496
+ }
497
+ } catch (error) {
498
+ console.error('Ошибка сохранения хотспота:', error);
499
+ WebApp.showAlert('Ошибка сети при сохранении хотспота.');
500
  }
501
+ }
502
  });
503
  }
504
 
505
  function haversineDistance(coords1, coords2) {
506
  function toRad(x) { return x * Math.PI / 180; }
507
+ const R = 6371;
508
  const dLat = toRad(coords2.lat - coords1.lat);
509
  const dLon = toRad(coords2.lon - coords1.lon);
510
+ const lat1 = toRad(coords1.lat);
511
+ const lat2 = toRad(coords2.lat);
512
  const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
513
+ Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
 
514
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
515
+ return R * c * 1000;
516
  }
517
 
518
  function calculateBearing(start, end) {
 
524
  const lon2 = toRad(end.lon);
525
  const y = Math.sin(lon2 - lon1) * Math.cos(lat2);
526
  const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1);
527
+ let brng = toDeg(Math.atan2(y, x));
528
+ return (brng + 360) % 360;
 
 
 
 
 
 
 
 
 
 
 
 
529
  }
530
 
531
  function update() {
532
+ if (!state.smoothedUserPosition) {
533
  requestAnimationFrame(update);
534
  return;
535
  }
536
 
 
 
537
  const screenWidth = window.innerWidth;
538
  const screenHeight = window.innerHeight;
539
 
 
541
  const el = document.getElementById(`hotspot-${index}`);
542
  if (!el) return;
543
 
544
+ const distance = haversineDistance(state.smoothedUserPosition, hotspot);
545
 
546
  if (distance > MAX_VISIBLE_DISTANCE) {
547
  el.classList.add('hidden');
548
  return;
549
  }
550
 
551
+ const bearing = calculateBearing(state.smoothedUserPosition, hotspot);
552
  let angleDiff = bearing - state.smoothedOrientation.alpha;
553
 
554
  if (angleDiff > 180) angleDiff -= 360;
555
  if (angleDiff < -180) angleDiff += 360;
556
+
557
  if (Math.abs(angleDiff) > state.cameraFov / 2) {
558
  el.classList.add('hidden');
559
  } else {
560
  el.classList.remove('hidden');
561
 
562
  const x = screenWidth / 2 + (angleDiff / (state.cameraFov / 2)) * (screenWidth / 2);
563
+ const y = screenHeight / 2;
564
+ const scale = Math.max(0.5, 1 - distance / MAX_VISIBLE_DISTANCE);
 
565
 
566
  el.style.left = `${x}px`;
567
  el.style.top = `${y}px`;
568
  el.style.transform = `translate(-50%, -50%) scale(${scale})`;
569
+ el.style.zIndex = Math.round(1000 - distance);
570
  }
571
  });
572
 
 
578
  '''
579
  return Response(html_content, mimetype='text/html')
580
 
581
+ @app.route('/validate_auth', methods=['POST'])
582
+ def validate_auth():
583
  data = request.get_json()
584
+ init_data_str = data.get('initData')
585
+ if not init_data_str:
586
+ return jsonify({"error": "No initData provided"}), 400
587
 
588
+ params = dict(urllib.parse.parse_qsl(init_data_str))
589
+
590
+ hash_from_telegram = params.pop('hash', None)
591
+ if not hash_from_telegram:
592
+ return jsonify({"error": "No hash found in initData"}), 400
593
 
594
+ data_check_string = "\n".join(sorted([f"{k}={v}" for k, v in params.items()]))
 
 
 
 
 
 
595
 
596
+ secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest()
597
+ h = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256)
598
+ calculated_hash = h.hexdigest()
599
+
600
+ if calculated_hash == hash_from_telegram:
601
+ try:
602
+ user_data = json.loads(params.get('user', '{}'))
603
+ return jsonify(user_data)
604
+ except json.JSONDecodeError:
605
+ return jsonify({"error": "Invalid user data"}), 400
606
+ else:
607
+ return jsonify({"error": "Invalid hash"}), 403
 
 
 
 
 
 
 
608
 
609
  @app.route('/hotspots', methods=['GET', 'POST'])
610
  def handle_hotspots():
 
620
  lat = data.get('lat')
621
  lon = data.get('lon')
622
  creator_id = data.get('creator_id')
623
+ creator_name = data.get('creator_name')
624
 
625
+ if not all([text, lat is not None, lon is not None, creator_id, creator_name]):
626
+ return jsonify({"error": "Missing required data"}), 400
627
 
628
  try:
629
  new_hotspot = {
 
631
  "lat": float(lat),
632
  "lon": float(lon),
633
  "creator_id": int(creator_id),
634
+ "creator_name": str(creator_name)
635
  }
636
  save_hotspot(new_hotspot)
637
  return jsonify({"success": True, "hotspot": new_hotspot}), 201