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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +255 -258
app.py CHANGED
@@ -1,10 +1,10 @@
1
- import flask
2
  from flask import Flask, Response, request, jsonify
 
3
  import json
4
  import os
5
  import hmac
6
  import hashlib
7
- import urllib.parse
8
 
9
  app = Flask(__name__)
10
 
@@ -16,7 +16,11 @@ def get_all_hotspots():
16
  return []
17
  try:
18
  with open(HOTSPOTS_FILE, 'r', encoding='utf-8') as f:
19
- return json.load(f)
 
 
 
 
20
  except (json.JSONDecodeError, FileNotFoundError):
21
  return []
22
 
@@ -40,12 +44,12 @@ def index():
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;
@@ -55,7 +59,7 @@ def index():
55
  overflow: hidden;
56
  background-color: var(--tg-bg-color);
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;
@@ -76,22 +80,27 @@ def index():
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
  }
@@ -101,199 +110,177 @@ def index():
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%;
119
- height: 100%;
120
  top: 0;
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,
270
  map: null,
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,30 +290,6 @@ def index():
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,7 +300,7 @@ def index():
337
  video.srcObject = stream;
338
  await video.play();
339
  } catch (err) {
340
- WebApp.showAlert('Не удалось получить доступ к камере: ' + err);
341
  }
342
  }
343
  }
@@ -348,66 +311,81 @@ def index():
348
  (position) => {
349
  state.currentUserPosition = {
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
  }
@@ -416,7 +394,7 @@ def index():
416
  try {
417
  const response = await fetch('/hotspots');
418
  const data = await response.json();
419
- state.hotspots = data;
420
  renderHotspots();
421
  renderHotspotsOnMap();
422
  } catch (error) {
@@ -431,7 +409,7 @@ def 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,43 +418,50 @@ def index():
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 {
@@ -486,17 +471,19 @@ def index():
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
  });
@@ -504,7 +491,7 @@ def index():
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);
@@ -512,7 +499,7 @@ def index():
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) {
@@ -529,82 +516,92 @@ def index():
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
-
540
  state.hotspots.forEach((hotspot, index) => {
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
-
573
- requestAnimationFrame(update);
574
  }
 
 
 
575
  </script>
576
  </body>
577
  </html>
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,9 +617,9 @@ 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:
@@ -631,12 +628,12 @@ def handle_hotspots():
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
638
- except (ValueError, TypeError):
639
- return jsonify({"error": "Invalid data types"}), 400
640
 
641
  if __name__ == '__main__':
642
  app.run(host='0.0.0.0', port=7860, debug=False)
 
 
1
  from flask import Flask, Response, request, jsonify
2
+ import requests
3
  import json
4
  import os
5
  import hmac
6
  import hashlib
7
+ from urllib.parse import unquote
8
 
9
  app = Flask(__name__)
10
 
 
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
 
 
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
  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
  }
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;
91
  white-space: nowrap;
92
  font-size: 16px;
93
+ font-weight: 500;
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
  }
 
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;
129
+ height: 100vh;
130
  top: 0;
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();
286
  setupOrientationListener();
 
290
  requestAnimationFrame(update);
291
  }
292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  async function setupCamera() {
294
  const video = document.getElementById('camera-view');
295
  if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
 
300
  video.srcObject = stream;
301
  await video.play();
302
  } catch (err) {
303
+ alert('Не удалось получить доступ к камере: ' + err);
304
  }
305
  }
306
  }
 
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]);
319
  if (!state.initialMapSet) {
320
+ state.map.setView([state.currentUserPosition.lat, state.currentUserPosition.lon], 16);
321
  state.initialMapSet = true;
322
  }
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
 
335
  function setupOrientationListener() {
336
+ const handleOrientation = (event) => {
337
+ if (event.alpha !== null) {
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
  }
 
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) {
 
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
  }
 
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 {
 
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
  });
 
491
 
492
  function haversineDistance(coords1, coords2) {
493
  function toRad(x) { return x * Math.PI / 180; }
494
+ const R = 6371000;
495
  const dLat = toRad(coords2.lat - coords1.lat);
496
  const dLon = toRad(coords2.lon - coords1.lon);
497
  const lat1 = toRad(coords1.lat);
 
499
  const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
500
  Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
501
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
502
+ return R * c;
503
  }
504
 
505
  function calculateBearing(start, end) {
 
516
  }
517
 
518
  function update() {
519
+ requestAnimationFrame(update);
520
+ if (!state.currentUserPosition) return;
 
 
521
 
522
  const screenWidth = window.innerWidth;
523
  const screenHeight = window.innerHeight;
524
+
525
  state.hotspots.forEach((hotspot, index) => {
526
  const el = document.getElementById(`hotspot-${index}`);
527
  if (!el) return;
528
 
529
+ const distance = haversineDistance(state.currentUserPosition, hotspot);
530
 
531
  if (distance > MAX_VISIBLE_DISTANCE) {
532
  el.classList.add('hidden');
533
  return;
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
 
560
  el.style.left = `${x}px`;
561
  el.style.top = `${y}px`;
562
  el.style.transform = `translate(-50%, -50%) scale(${scale})`;
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():
 
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:
 
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)