Aleksmorshen commited on
Commit
a408963
·
verified ·
1 Parent(s): 2fe5d08

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +216 -310
app.py CHANGED
@@ -1,5 +1,4 @@
1
  from flask import Flask, Response, request, jsonify
2
- import requests
3
  import json
4
  import os
5
  import hmac
@@ -16,11 +15,7 @@ def get_all_hotspots():
16
  return []
17
  try:
18
  with open(HOTSPOTS_FILE, 'r', encoding='utf-8') as f:
19
- # Handle empty file case
20
- content = f.read()
21
- if not content:
22
- return []
23
- return json.loads(content)
24
  except (json.JSONDecodeError, FileNotFoundError):
25
  return []
26
 
@@ -30,6 +25,34 @@ def save_hotspot(new_hotspot):
30
  with open(HOTSPOTS_FILE, 'w', encoding='utf-8') as f:
31
  json.dump(hotspots, f, ensure_ascii=False, indent=4)
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  @app.route('/')
34
  def index():
35
  html_content = '''
@@ -44,12 +67,11 @@ def index():
44
  <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
45
  <style>
46
  :root {
47
- --tg-bg-color: var(--tg-theme-bg-color, #000000);
48
- --tg-text-color: var(--tg-theme-text-color, #ffffff);
 
49
  --tg-button-color: var(--tg-theme-button-color, #007aff);
50
- --tg-button-text-color: var(--tg-theme-button-text-color, #ffffff);
51
- --tg-hint-color: var(--tg-theme-hint-color, #aaaaaa);
52
- --tg-secondary-bg-color: var(--tg-theme-secondary-bg-color, #1c1c1d);
53
  }
54
  body, html {
55
  margin: 0;
@@ -59,7 +81,7 @@ def index():
59
  overflow: hidden;
60
  background-color: var(--tg-bg-color);
61
  color: var(--tg-text-color);
62
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
63
  }
64
  #ar-container {
65
  position: absolute;
@@ -80,11 +102,11 @@ def index():
80
  }
81
  .hotspot {
82
  position: absolute;
83
- background-color: rgba(0, 122, 255, 0.9);
84
  color: white;
85
  padding: 10px 15px;
86
  border-radius: 12px;
87
- border: 1px solid rgba(255, 255, 255, 0.3);
88
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
89
  transform: translate(-50%, -50%);
90
  transition: opacity 0.3s, transform 0.1s linear;
@@ -94,15 +116,15 @@ def index():
94
  will-change: transform, left, top, opacity;
95
  z-index: 10;
96
  text-align: center;
97
- backdrop-filter: blur(10px);
98
- -webkit-backdrop-filter: blur(10px);
99
  }
100
  .hotspot small {
101
  font-size: 0.75em;
102
- font-weight: 400;
103
  opacity: 0.85;
104
  display: block;
105
  margin-top: 5px;
 
106
  }
107
  .hotspot.hidden {
108
  opacity: 0;
@@ -110,19 +132,17 @@ def index():
110
  }
111
  #map-container {
112
  position: fixed;
113
- bottom: 15px;
114
- left: 15px;
115
- width: 150px;
 
116
  height: 150px;
117
- background-color: var(--tg-secondary-bg-color);
118
- border-radius: 20px;
119
  overflow: hidden;
120
- transition: all 0.3s ease;
121
  z-index: 50;
122
- display: flex;
123
- flex-direction: column;
124
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.37);
125
- border: 1px solid rgba(255, 255, 255, 0.1);
126
  }
127
  #map-container.fullscreen {
128
  width: 100vw;
@@ -131,155 +151,69 @@ def index():
131
  left: 0;
132
  bottom: 0;
133
  border-radius: 0;
 
134
  border: none;
135
  }
136
  #map {
137
- flex-grow: 1;
138
  width: 100%;
139
  height: 100%;
140
  }
141
- .leaflet-control-container .leaflet-control {
142
- background-color: var(--tg-secondary-bg-color);
143
- color: var(--tg-text-color);
144
- }
145
- .leaflet-popup-content-wrapper, .leaflet-popup-tip {
146
- background-color: var(--tg-secondary-bg-color);
147
- color: var(--tg-text-color);
148
- }
149
- #toggle-map-button {
150
- position: absolute;
151
- top: 5px;
152
- right: 5px;
153
- background-color: rgba(0, 0, 0, 0.5);
154
- color: white;
155
- border: none;
156
- padding: 5px;
157
- border-radius: 50%;
158
- cursor: pointer;
159
- font-size: 12px;
160
- z-index: 1000;
161
- width: 30px;
162
- height: 30px;
163
  }
164
- #add-hotspot-modal {
165
  position: fixed;
166
- bottom: 0;
167
- left: 0;
168
- width: 100%;
169
- background-color: var(--tg-secondary-bg-color);
170
- z-index: 200;
171
- padding: 20px;
172
- box-sizing: border-box;
173
- border-top-left-radius: 20px;
174
- border-top-right-radius: 20px;
175
- transform: translateY(100%);
176
- transition: transform 0.3s ease-out;
177
- padding-bottom: calc(20px + env(safe-area-inset-bottom));
178
- }
179
- #add-hotspot-modal.visible {
180
- transform: translateY(0);
181
- }
182
- #hotspot-input {
183
- width: 100%;
184
- padding: 12px;
185
- border-radius: 10px;
186
- border: 1px solid var(--tg-hint-color);
187
- background-color: var(--tg-bg-color);
188
- color: var(--tg-text-color);
189
- font-size: 16px;
190
- margin-bottom: 10px;
191
- box-sizing: border-box;
192
- }
193
- #submit-hotspot {
194
- width: 100%;
195
- padding: 14px;
196
- border-radius: 10px;
197
- border: none;
198
- background-color: var(--tg-button-color);
199
- color: var(--tg-button-text-color);
200
- font-size: 16px;
201
- font-weight: bold;
202
- cursor: pointer;
203
- }
204
- #loading-overlay {
205
- position: fixed;
206
- top: 0;
207
- left: 0;
208
- width: 100%;
209
- height: 100%;
210
- background-color: var(--tg-bg-color);
211
- z-index: 9999;
212
- display: flex;
213
- align-items: center;
214
- justify-content: center;
215
- color: var(--tg-text-color);
216
- font-size: 18px;
217
  }
218
  </style>
219
  </head>
220
  <body>
221
- <div id="loading-overlay">Загрузка...</div>
222
-
223
  <video id="camera-view" playsinline autoplay muted></video>
224
  <div id="ar-container"></div>
225
-
226
  <div id="map-container">
227
- <button id="toggle-map-button">⛶</button>
228
  <div id="map"></div>
229
  </div>
230
 
231
- <div id="add-hotspot-modal">
232
- <input type="text" id="hotspot-input" placeholder="Текст вашего хотспота...">
233
- <button id="submit-hotspot">Оставить метку</button>
234
- </div>
235
-
236
  <script>
237
  const tg = window.Telegram.WebApp;
238
-
 
 
239
  const state = {
240
  hotspots: [],
241
  currentUserPosition: null,
242
- currentUser: null,
243
- deviceOrientation: { alpha: 0, beta: 0, gamma: 0, absolute: false },
 
244
  cameraFov: 60,
245
  map: null,
246
  userMarker: null,
247
  hotspotMarkers: [],
248
- initialMapSet: false
 
249
  };
250
 
251
- const MAX_VISIBLE_DISTANCE = 500;
252
- const SMOOTHING_FACTOR = 0.1;
253
-
254
- async function main() {
255
- tg.ready();
256
- tg.expand();
257
- tg.MainButton.hide();
258
 
259
- if (!tg.initData) {
260
- document.getElementById('loading-overlay').innerText = "Ошибка: не удалось получить данные Telegram.";
261
- return;
262
- }
263
-
264
- try {
265
- const res = await fetch('/validate_init_data', {
266
- method: 'POST',
267
- headers: { 'Content-Type': 'application/json' },
268
- body: JSON.stringify({ init_data: tg.initData })
269
- });
270
- if (!res.ok) throw new Error('Validation failed');
271
-
272
- const userData = await res.json();
273
- state.currentUser = userData.user;
274
-
275
- document.getElementById('loading-overlay').style.display = 'none';
276
- initArApp();
277
- } catch (e) {
278
- document.getElementById('loading-overlay').innerText = `Ошибка авторизации: ${e.message}`;
279
- console.error(e);
280
- }
281
  }
282
 
 
 
283
  async function initArApp() {
284
  await setupCamera();
285
  setupGPS();
@@ -300,7 +234,7 @@ def index():
300
  video.srcObject = stream;
301
  await video.play();
302
  } catch (err) {
303
- alert('Не удалось получить доступ к камере: ' + err);
304
  }
305
  }
306
  }
@@ -311,8 +245,7 @@ def index():
311
  (position) => {
312
  state.currentUserPosition = {
313
  lat: position.coords.latitude,
314
- lon: position.coords.longitude,
315
- accuracy: position.coords.accuracy
316
  };
317
  if (state.userMarker) {
318
  state.userMarker.setLatLng([state.currentUserPosition.lat, state.currentUserPosition.lon]);
@@ -323,12 +256,12 @@ def index():
323
  }
324
  },
325
  (error) => {
326
- alert('Не удалось получить доступ к GPS: ' + error.message);
327
  },
328
- { enableHighAccuracy: true, maximumAge: 1000, timeout: 5000 }
329
  );
330
  } else {
331
- alert('GPS не поддерживается вашим браузером.');
332
  }
333
  }
334
 
@@ -338,67 +271,69 @@ def index():
338
  state.deviceOrientation.alpha = event.alpha;
339
  state.deviceOrientation.beta = event.beta;
340
  state.deviceOrientation.gamma = event.gamma;
341
- state.deviceOrientation.absolute = event.absolute;
 
 
 
 
 
 
 
342
  }
343
  };
344
-
345
- if ('DeviceOrientationEvent' in window && typeof DeviceOrientationEvent.requestPermission === 'function') {
346
- DeviceOrientationEvent.requestPermission()
347
- .then(permissionState => {
348
- if (permissionState === 'granted') {
349
- window.addEventListener('deviceorientationabsolute', handleOrientation, true);
350
- } else {
351
- alert('Доступ к ориентации устройства не предоставлен.');
352
- }
353
- })
354
- .catch(console.error);
 
 
 
 
355
  } else {
356
- window.addEventListener('deviceorientationabsolute', handleOrientation, true);
357
- window.addEventListener('deviceorientation', (e) => {
358
- if (!state.deviceOrientation.absolute) handleOrientation(e);
359
- }, true);
360
  }
361
  }
362
 
363
  function initMap() {
364
  state.map = L.map('map', { zoomControl: false }).setView([0, 0], 2);
365
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
366
- attribution: '© OpenStreetMap',
367
- crossOrigin: true
368
- }).addTo(state.map);
369
 
370
- const userIcon = L.divIcon({
371
- html: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="'+tg.themeParams.button_color+'" d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/><path fill="none" d="M0 0h24v24H0z"/></svg>',
372
- className: '',
373
- iconSize: [24, 24],
374
- iconAnchor: [12, 24]
 
 
 
 
 
 
 
 
 
 
 
 
375
  });
376
-
377
- state.userMarker = L.marker([0, 0], { icon: userIcon }).addTo(state.map).bindPopup('Вы здесь');
378
- document.getElementById('toggle-map-button').addEventListener('click', toggleMap);
379
- }
380
-
381
- function toggleMap(e) {
382
- e.stopPropagation();
383
- const mapContainer = document.getElementById('map-container');
384
- mapContainer.classList.toggle('fullscreen');
385
- setTimeout(() => {
386
- state.map.invalidateSize();
387
- if (state.currentUserPosition) {
388
- state.map.setView([state.currentUserPosition.lat, state.currentUserPosition.lon], state.map.getZoom());
389
- }
390
- }, 300);
391
  }
392
 
393
  async function loadHotspots() {
394
  try {
395
  const response = await fetch('/hotspots');
396
  const data = await response.json();
397
- state.hotspots = data.map(h => ({ ...h, smoothedAngleDiff: 0 }));
398
  renderHotspots();
399
  renderHotspotsOnMap();
400
  } catch (error) {
401
- console.error('Ошибка загрузки хотспотов:', error);
402
  }
403
  }
404
 
@@ -409,86 +344,82 @@ def index():
409
  const el = document.createElement('div');
410
  el.className = 'hotspot';
411
  el.id = `hotspot-${index}`;
412
- el.innerHTML = `${hotspot.text}<br><small>@${hotspot.creator_username || 'anonymous'}</small>`;
413
  container.appendChild(el);
414
  });
415
  }
416
 
417
  function renderHotspotsOnMap() {
418
  if (!state.map) return;
 
419
  state.hotspotMarkers.forEach(marker => state.map.removeLayer(marker));
420
  state.hotspotMarkers = [];
 
421
  state.hotspots.forEach(hotspot => {
422
  const marker = L.marker([hotspot.lat, hotspot.lon])
423
  .addTo(state.map)
424
- .bindPopup(`${hotspot.text}<br>@${hotspot.creator_username || 'anonymous'}`);
425
  state.hotspotMarkers.push(marker);
426
  });
427
  }
428
 
429
  function setupAddHotspotListener() {
430
- const container = document.getElementById('ar-container');
431
- const modal = document.getElementById('add-hotspot-modal');
432
- const input = document.getElementById('hotspot-input');
433
- const submitBtn = document.getElementById('submit-hotspot');
434
-
435
- container.addEventListener('click', (event) => {
436
- if (event.target !== container) return;
437
-
438
- if (!state.currentUserPosition) {
439
- tg.showAlert('GPS-координаты еще не определены. Подождите немного.');
440
- return;
441
- }
442
- if (state.currentUserPosition.accuracy > 20) {
443
- tg.showAlert(`Точность GPS слишком низкая (${Math.round(state.currentUserPosition.accuracy)}м). Попробуйте найти открытое пространство.`);
444
- return;
445
- }
446
- modal.classList.add('visible');
447
- input.focus();
448
  });
 
 
 
 
 
 
 
449
 
450
- document.body.addEventListener('click', (e) => {
451
- if (modal.classList.contains('visible') && !modal.contains(e.target)) {
452
- modal.classList.remove('visible');
453
- }
454
- });
 
455
 
456
- submitBtn.addEventListener('click', async () => {
457
- const text = input.value.trim();
458
- if (text) {
459
- const newHotspot = {
460
- text: text,
461
- lat: state.currentUserPosition.lat,
462
- lon: state.currentUserPosition.lon,
463
- creator_id: state.currentUser.id,
464
- creator_username: state.currentUser.username || `${state.currentUser.first_name}`.trim()
465
- };
466
-
467
- try {
468
- const response = await fetch('/hotspots', {
469
- method: 'POST',
470
- headers: { 'Content-Type': 'application/json' },
471
- body: JSON.stringify(newHotspot)
472
- });
473
- if(response.ok) {
474
- const savedHotspotData = await response.json();
475
- const savedHotspot = { ...savedHotspotData.hotspot, smoothedAngleDiff: 0 };
476
- state.hotspots.push(savedHotspot);
477
- renderHotspots();
478
- renderHotspotsOnMap();
479
- input.value = '';
480
- modal.classList.remove('visible');
481
- } else {
482
- tg.showAlert('Не удалось сохранить хотспот.');
483
- }
484
- } catch (error) {
485
- console.error('Ошибка сохранения хотспота:', error);
486
- tg.showAlert('Ошибка сети при сохранении хотспота.');
487
- }
488
  }
489
- });
 
 
490
  }
491
 
 
492
  function haversineDistance(coords1, coords2) {
493
  function toRad(x) { return x * Math.PI / 180; }
494
  const R = 6371000;
@@ -505,19 +436,18 @@ def index():
505
  function calculateBearing(start, end) {
506
  function toRad(x) { return x * Math.PI / 180; }
507
  function toDeg(x) { return x * 180 / Math.PI; }
508
- const lat1 = toRad(start.lat);
509
- const lon1 = toRad(start.lon);
510
- const lat2 = toRad(end.lat);
511
- const lon2 = toRad(end.lon);
512
- const y = Math.sin(lon2 - lon1) * Math.cos(lat2);
513
- const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1);
514
  let brng = toDeg(Math.atan2(y, x));
515
  return (brng + 360) % 360;
516
  }
517
 
518
  function update() {
519
- requestAnimationFrame(update);
520
- if (!state.currentUserPosition) return;
 
 
521
 
522
  const screenWidth = window.innerWidth;
523
  const screenHeight = window.innerHeight;
@@ -534,26 +464,20 @@ def index():
534
  }
535
 
536
  const bearing = calculateBearing(state.currentUserPosition, hotspot);
537
- let angleDiff = bearing - state.deviceOrientation.alpha;
538
 
539
  if (angleDiff > 180) angleDiff -= 360;
540
  if (angleDiff < -180) angleDiff += 360;
541
-
542
- const currentSmoothed = hotspot.smoothedAngleDiff;
543
- let delta = angleDiff - currentSmoothed;
544
- if (delta > 180) delta -= 360;
545
- if (delta < -180) delta += 360;
546
-
547
- hotspot.smoothedAngleDiff = currentSmoothed + delta * SMOOTHING_FACTOR;
548
- const smoothedAngle = hotspot.smoothedAngleDiff;
549
 
550
- if (Math.abs(smoothedAngle) > state.cameraFov / 2) {
551
  el.classList.add('hidden');
552
  } else {
553
  el.classList.remove('hidden');
554
 
555
- const x = screenWidth / 2 + (smoothedAngle / (state.cameraFov / 2)) * (screenWidth / 2);
556
- const y = screenHeight / 2 - (state.deviceOrientation.beta - 90) * 2;
 
 
557
 
558
  const scale = Math.max(0.4, 1.2 - distance / MAX_VISIBLE_DISTANCE);
559
 
@@ -563,46 +487,17 @@ def index():
563
  el.style.zIndex = Math.round(10000 - distance);
564
  }
565
  });
 
 
566
  }
567
 
568
- main();
569
-
570
  </script>
571
  </body>
572
  </html>
573
  '''
574
  return Response(html_content, mimetype='text/html')
575
 
576
-
577
- @app.route('/validate_init_data', methods=['POST'])
578
- def validate_init_data():
579
- try:
580
- data = request.get_json()
581
- init_data_str = data.get('init_data')
582
-
583
- if not init_data_str:
584
- return jsonify({"error": "No init_data provided"}), 400
585
-
586
- params = {k: unquote(v) for k, v in [pair.split('=', 1) for pair in init_data_str.split('&')]}
587
-
588
- hash_from_telegram = params.pop('hash', None)
589
- if not hash_from_telegram:
590
- return jsonify({"error": "No hash found in init_data"}), 400
591
-
592
- data_check_string = "\n".join(sorted([f"{k}={v}" for k, v in params.items()]))
593
-
594
- secret_key = hmac.new(key=b"WebAppData", msg=BOT_TOKEN.encode(), digestmod=hashlib.sha256).digest()
595
- calculated_hash = hmac.new(key=secret_key, msg=data_check_string.encode(), digestmod=hashlib.sha256).hexdigest()
596
-
597
- if calculated_hash == hash_from_telegram:
598
- user_data = json.loads(params.get('user', '{}'))
599
- return jsonify({"status": "ok", "user": user_data})
600
- else:
601
- return jsonify({"error": "Invalid hash"}), 403
602
-
603
- except Exception as e:
604
- return jsonify({"error": str(e)}), 500
605
-
606
  @app.route('/hotspots', methods=['GET', 'POST'])
607
  def handle_hotspots():
608
  if request.method == 'GET':
@@ -613,27 +508,38 @@ def handle_hotspots():
613
  return jsonify({"error": "Missing JSON in request"}), 400
614
 
615
  data = request.get_json()
 
 
 
 
 
 
 
 
 
616
  text = data.get('text')
617
  lat = data.get('lat')
618
  lon = data.get('lon')
619
- creator_id = data.get('creator_id')
620
- creator_username = data.get('creator_username')
621
 
622
- if not all([text, lat is not None, lon is not None, creator_id]):
623
- return jsonify({"error": "Missing required data"}), 400
624
 
625
  try:
 
 
 
 
626
  new_hotspot = {
627
  "text": str(text),
628
  "lat": float(lat),
629
  "lon": float(lon),
630
- "creator_id": int(creator_id),
631
- "creator_username": str(creator_username)
632
  }
633
  save_hotspot(new_hotspot)
634
  return jsonify({"success": True, "hotspot": new_hotspot}), 201
635
- except (ValueError, TypeError) as e:
636
- return jsonify({"error": f"Invalid data types: {e}"}), 400
637
 
638
  if __name__ == '__main__':
639
  app.run(host='0.0.0.0', port=7860, debug=False)
 
1
  from flask import Flask, Response, request, jsonify
 
2
  import json
3
  import os
4
  import hmac
 
15
  return []
16
  try:
17
  with open(HOTSPOTS_FILE, 'r', encoding='utf-8') as f:
18
+ return json.load(f)
 
 
 
 
19
  except (json.JSONDecodeError, FileNotFoundError):
20
  return []
21
 
 
25
  with open(HOTSPOTS_FILE, 'w', encoding='utf-8') as f:
26
  json.dump(hotspots, f, ensure_ascii=False, indent=4)
27
 
28
+ def is_data_safe(init_data: str) -> (bool, dict):
29
+ try:
30
+ encoded_data = unquote(init_data)
31
+ data_check_string = []
32
+ recieved_hash = ''
33
+
34
+ for pair in encoded_data.split('&'):
35
+ key, value = pair.split('=', 1)
36
+ if key == 'hash':
37
+ recieved_hash = value
38
+ else:
39
+ data_check_string.append(f"{key}={value}")
40
+
41
+ data_check_string.sort()
42
+ data_check_string = "\n".join(data_check_string)
43
+
44
+ secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest()
45
+ calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
46
+
47
+ if calculated_hash == recieved_hash:
48
+ data = {k: v for k, v in [pair.split('=', 1) for pair in encoded_data.split('&')]}
49
+ user_data = json.loads(unquote(data['user']))
50
+ return True, user_data
51
+ return False, None
52
+
53
+ except Exception:
54
+ return False, None
55
+
56
  @app.route('/')
57
  def index():
58
  html_content = '''
 
67
  <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
68
  <style>
69
  :root {
70
+ --tg-bg-color: var(--tg-theme-bg-color, #000);
71
+ --tg-text-color: var(--tg-theme-text-color, #fff);
72
+ --tg-hint-color: var(--tg-theme-hint-color, #aaa);
73
  --tg-button-color: var(--tg-theme-button-color, #007aff);
74
+ --tg-button-text-color: var(--tg-theme-button-text-color, #fff);
 
 
75
  }
76
  body, html {
77
  margin: 0;
 
81
  overflow: hidden;
82
  background-color: var(--tg-bg-color);
83
  color: var(--tg-text-color);
84
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
85
  }
86
  #ar-container {
87
  position: absolute;
 
102
  }
103
  .hotspot {
104
  position: absolute;
105
+ background-color: rgba(0, 122, 255, 0.85);
106
  color: white;
107
  padding: 10px 15px;
108
  border-radius: 12px;
109
+ border: 1px solid rgba(255, 255, 255, 0.5);
110
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
111
  transform: translate(-50%, -50%);
112
  transition: opacity 0.3s, transform 0.1s linear;
 
116
  will-change: transform, left, top, opacity;
117
  z-index: 10;
118
  text-align: center;
119
+ backdrop-filter: blur(5px);
120
+ -webkit-backdrop-filter: blur(5px);
121
  }
122
  .hotspot small {
123
  font-size: 0.75em;
 
124
  opacity: 0.85;
125
  display: block;
126
  margin-top: 5px;
127
+ font-weight: 400;
128
  }
129
  .hotspot.hidden {
130
  opacity: 0;
 
132
  }
133
  #map-container {
134
  position: fixed;
135
+ bottom: 20px;
136
+ left: 50%;
137
+ transform: translateX(-50%);
138
+ width: 220px;
139
  height: 150px;
140
+ border-radius: 15px;
 
141
  overflow: hidden;
142
+ transition: all 0.3s ease-in-out;
143
  z-index: 50;
144
+ border: 1px solid var(--tg-hint-color);
145
+ box-shadow: 0 5px 25px rgba(0,0,0,0.5);
 
 
146
  }
147
  #map-container.fullscreen {
148
  width: 100vw;
 
151
  left: 0;
152
  bottom: 0;
153
  border-radius: 0;
154
+ transform: none;
155
  border: none;
156
  }
157
  #map {
 
158
  width: 100%;
159
  height: 100%;
160
  }
161
+ .leaflet-control-container .leaflet-control-attribution {
162
+ display: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  }
164
+ #welcome-message {
165
  position: fixed;
166
+ top: 20px;
167
+ left: 50%;
168
+ transform: translateX(-50%);
169
+ background-color: rgba(0,0,0,0.6);
170
+ padding: 8px 15px;
171
+ border-radius: 20px;
172
+ z-index: 51;
173
+ font-size: 14px;
174
+ backdrop-filter: blur(5px);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  }
176
  </style>
177
  </head>
178
  <body>
179
+ <div id="welcome-message"></div>
 
180
  <video id="camera-view" playsinline autoplay muted></video>
181
  <div id="ar-container"></div>
 
182
  <div id="map-container">
 
183
  <div id="map"></div>
184
  </div>
185
 
 
 
 
 
 
186
  <script>
187
  const tg = window.Telegram.WebApp;
188
+ tg.ready();
189
+ tg.expand();
190
+
191
  const state = {
192
  hotspots: [],
193
  currentUserPosition: null,
194
+ deviceOrientation: { alpha: 0, beta: 0, gamma: 0 },
195
+ smoothedAlpha: 0,
196
+ smoothingFactor: 0.05,
197
  cameraFov: 60,
198
  map: null,
199
  userMarker: null,
200
  hotspotMarkers: [],
201
+ initialMapSet: false,
202
+ userInfo: tg.initDataUnsafe.user || {}
203
  };
204
 
205
+ const MAX_VISIBLE_DISTANCE = 1000;
 
 
 
 
 
 
206
 
207
+ function formatUserName(user) {
208
+ if (!user) return "Аноним";
209
+ let name = user.first_name || '';
210
+ if (user.last_name) name += ` ${user.last_name}`;
211
+ if (user.username) name += ` (@${user.username})`;
212
+ return name.trim();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  }
214
 
215
+ document.getElementById('welcome-message').innerText = `Привет, ${state.userInfo.first_name || 'пользователь'}!`;
216
+
217
  async function initArApp() {
218
  await setupCamera();
219
  setupGPS();
 
234
  video.srcObject = stream;
235
  await video.play();
236
  } catch (err) {
237
+ tg.showAlert('Не удалось получить доступ к камере: ' + err.message);
238
  }
239
  }
240
  }
 
245
  (position) => {
246
  state.currentUserPosition = {
247
  lat: position.coords.latitude,
248
+ lon: position.coords.longitude
 
249
  };
250
  if (state.userMarker) {
251
  state.userMarker.setLatLng([state.currentUserPosition.lat, state.currentUserPosition.lon]);
 
256
  }
257
  },
258
  (error) => {
259
+ tg.showAlert('Не удалось получить доступ к GPS: ' + error.message);
260
  },
261
+ { enableHighAccuracy: true, maximumAge: 0, timeout: 5000 }
262
  );
263
  } else {
264
+ tg.showAlert('GPS не поддерживается вашим устройством.');
265
  }
266
  }
267
 
 
271
  state.deviceOrientation.alpha = event.alpha;
272
  state.deviceOrientation.beta = event.beta;
273
  state.deviceOrientation.gamma = event.gamma;
274
+
275
+ let alpha = event.webkitCompassHeading || event.alpha;
276
+ let diff = alpha - state.smoothedAlpha;
277
+ if (diff > 180) diff -= 360;
278
+ if (diff < -180) diff += 360;
279
+
280
+ state.smoothedAlpha += state.smoothingFactor * diff;
281
+ state.smoothedAlpha = (state.smoothedAlpha + 360) % 360;
282
  }
283
  };
284
+
285
+ if (window.DeviceOrientationEvent) {
286
+ if (typeof DeviceOrientationEvent.requestPermission === 'function') {
287
+ DeviceOrientationEvent.requestPermission()
288
+ .then(permissionState => {
289
+ if (permissionState === 'granted') {
290
+ window.addEventListener('deviceorientation', handleOrientation, true);
291
+ } else {
292
+ tg.showAlert('Доступ к ориентации устройства отклонен.');
293
+ }
294
+ })
295
+ .catch(console.error);
296
+ } else {
297
+ window.addEventListener('deviceorientation', handleOrientation, true);
298
+ }
299
  } else {
300
+ tg.showAlert('Отслеживание ориентации устройства не поддерживается.');
 
 
 
301
  }
302
  }
303
 
304
  function initMap() {
305
  state.map = L.map('map', { zoomControl: false }).setView([0, 0], 2);
306
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(state.map);
 
 
 
307
 
308
+ var userIcon = L.divIcon({
309
+ className: 'user-location-dot',
310
+ html: '<div style="background-color: #007aff; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white;"></div>',
311
+ iconSize: [16, 16],
312
+ iconAnchor: [8, 8]
313
+ });
314
+ state.userMarker = L.marker([0, 0], { icon: userIcon }).addTo(state.map);
315
+
316
+ document.getElementById('map-container').addEventListener('click', () => {
317
+ const mapContainer = document.getElementById('map-container');
318
+ mapContainer.classList.toggle('fullscreen');
319
+ setTimeout(() => {
320
+ state.map.invalidateSize();
321
+ if (state.currentUserPosition) {
322
+ state.map.setView([state.currentUserPosition.lat, state.currentUserPosition.lon], state.map.getZoom());
323
+ }
324
+ }, 310);
325
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  }
327
 
328
  async function loadHotspots() {
329
  try {
330
  const response = await fetch('/hotspots');
331
  const data = await response.json();
332
+ state.hotspots = data;
333
  renderHotspots();
334
  renderHotspotsOnMap();
335
  } catch (error) {
336
+ tg.showAlert('Ошибка загрузки хотспотов: ' + error.message);
337
  }
338
  }
339
 
 
344
  const el = document.createElement('div');
345
  el.className = 'hotspot';
346
  el.id = `hotspot-${index}`;
347
+ el.innerHTML = `${hotspot.text}<br><small>by ${hotspot.creator_info || 'Unknown'}</small>`;
348
  container.appendChild(el);
349
  });
350
  }
351
 
352
  function renderHotspotsOnMap() {
353
  if (!state.map) return;
354
+
355
  state.hotspotMarkers.forEach(marker => state.map.removeLayer(marker));
356
  state.hotspotMarkers = [];
357
+
358
  state.hotspots.forEach(hotspot => {
359
  const marker = L.marker([hotspot.lat, hotspot.lon])
360
  .addTo(state.map)
361
+ .bindPopup(`<b>${hotspot.text}</b><br>by ${hotspot.creator_info || 'Unknown'}`);
362
  state.hotspotMarkers.push(marker);
363
  });
364
  }
365
 
366
  function setupAddHotspotListener() {
367
+ document.body.addEventListener('dblclick', (event) => {
368
+ if (event.target.closest('#map-container')) return;
369
+
370
+ tg.showPopup({
371
+ title: 'Новый хотспот',
372
+ message: 'Введите текст для новой AR-метки. Она будет создана в вашем текущем местоположении.',
373
+ buttons: [
374
+ {id: 'create', type: 'default', text: 'Создать'},
375
+ {type: 'cancel'},
376
+ ]
377
+ }, async (buttonId) => {
378
+ if (buttonId === 'create') {
379
+ const text = prompt("Введите текст для хотспота:");
380
+ if (text && text.trim() !== '') {
381
+ await createHotspot(text.trim());
382
+ }
383
+ }
384
+ });
385
  });
386
+ }
387
+
388
+ async function createHotspot(text) {
389
+ if (!state.currentUserPosition) {
390
+ tg.showAlert('GPS-координаты еще не определены. Подождите и попробуйте снова.');
391
+ return;
392
+ }
393
 
394
+ const newHotspotData = {
395
+ text: text,
396
+ lat: state.currentUserPosition.lat,
397
+ lon: state.currentUserPosition.lon,
398
+ initData: tg.initData
399
+ };
400
 
401
+ try {
402
+ const response = await fetch('/hotspots', {
403
+ method: 'POST',
404
+ headers: { 'Content-Type': 'application/json' },
405
+ body: JSON.stringify(newHotspotData)
406
+ });
407
+ if (response.ok) {
408
+ const savedHotspot = await response.json();
409
+ state.hotspots.push(savedHotspot.hotspot);
410
+ renderHotspots();
411
+ renderHotspotsOnMap();
412
+ tg.HapticFeedback.notificationOccurred('success');
413
+ } else {
414
+ const error = await response.json();
415
+ tg.showAlert(`Не удалось сохранить хотспот: ${error.error}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  }
417
+ } catch (error) {
418
+ tg.showAlert('Сетевая ошибка при сохранении хотспота.');
419
+ }
420
  }
421
 
422
+
423
  function haversineDistance(coords1, coords2) {
424
  function toRad(x) { return x * Math.PI / 180; }
425
  const R = 6371000;
 
436
  function calculateBearing(start, end) {
437
  function toRad(x) { return x * Math.PI / 180; }
438
  function toDeg(x) { return x * 180 / Math.PI; }
439
+ const y = Math.sin(toRad(end.lon - start.lon)) * Math.cos(toRad(end.lat));
440
+ const x = Math.cos(toRad(start.lat)) * Math.sin(toRad(end.lat)) -
441
+ Math.sin(toRad(start.lat)) * Math.cos(toRad(end.lat)) * Math.cos(toRad(end.lon - start.lon));
 
 
 
442
  let brng = toDeg(Math.atan2(y, x));
443
  return (brng + 360) % 360;
444
  }
445
 
446
  function update() {
447
+ if (!state.currentUserPosition) {
448
+ requestAnimationFrame(update);
449
+ return;
450
+ }
451
 
452
  const screenWidth = window.innerWidth;
453
  const screenHeight = window.innerHeight;
 
464
  }
465
 
466
  const bearing = calculateBearing(state.currentUserPosition, hotspot);
467
+ let angleDiff = bearing - state.smoothedAlpha;
468
 
469
  if (angleDiff > 180) angleDiff -= 360;
470
  if (angleDiff < -180) angleDiff += 360;
 
 
 
 
 
 
 
 
471
 
472
+ if (Math.abs(angleDiff) > state.cameraFov / 2) {
473
  el.classList.add('hidden');
474
  } else {
475
  el.classList.remove('hidden');
476
 
477
+ const x = screenWidth / 2 + (angleDiff / (state.cameraFov / 2)) * (screenWidth / 2);
478
+
479
+ const verticalAngle = -state.deviceOrientation.beta;
480
+ const y = screenHeight / 2 - (Math.tan(verticalAngle * Math.PI / 180) * (screenHeight / 2));
481
 
482
  const scale = Math.max(0.4, 1.2 - distance / MAX_VISIBLE_DISTANCE);
483
 
 
487
  el.style.zIndex = Math.round(10000 - distance);
488
  }
489
  });
490
+
491
+ requestAnimationFrame(update);
492
  }
493
 
494
+ initArApp();
 
495
  </script>
496
  </body>
497
  </html>
498
  '''
499
  return Response(html_content, mimetype='text/html')
500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  @app.route('/hotspots', methods=['GET', 'POST'])
502
  def handle_hotspots():
503
  if request.method == 'GET':
 
508
  return jsonify({"error": "Missing JSON in request"}), 400
509
 
510
  data = request.get_json()
511
+ init_data = data.get('initData')
512
+
513
+ if not init_data:
514
+ return jsonify({"error": "Missing initData"}), 401
515
+
516
+ is_safe, user_data = is_data_safe(init_data)
517
+ if not is_safe:
518
+ return jsonify({"error": "Validation failed"}), 403
519
+
520
  text = data.get('text')
521
  lat = data.get('lat')
522
  lon = data.get('lon')
 
 
523
 
524
+ if not all([text, lat, lon]):
525
+ return jsonify({"error": "Missing data: text, lat, or lon"}), 400
526
 
527
  try:
528
+ creator_info = user_data.get('first_name', 'User')
529
+ if user_data.get('username'):
530
+ creator_info = f"{creator_info} (@{user_data.get('username')})"
531
+
532
  new_hotspot = {
533
  "text": str(text),
534
  "lat": float(lat),
535
  "lon": float(lon),
536
+ "creator_info": creator_info,
537
+ "creator_id": user_data.get('id')
538
  }
539
  save_hotspot(new_hotspot)
540
  return jsonify({"success": True, "hotspot": new_hotspot}), 201
541
+ except (ValueError, TypeError):
542
+ return jsonify({"error": "Invalid data types"}), 400
543
 
544
  if __name__ == '__main__':
545
  app.run(host='0.0.0.0', port=7860, debug=False)