Merge pull request 'dev' (#1) from dev into main
Reviewed-on: http://sergeevrov.fvds.ru:3000/Vic/SatForecast/pulls/1
10
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
8
.idea/SatForecast.iml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="jdk" jdkName="Python 3.12 (SatForecast)" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
7
.idea/misc.xml
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Black">
|
||||||
|
<option name="sdkName" value="Python 3.14" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (SatForecast)" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/SatForecast.iml" filepath="$PROJECT_DIR$/.idea/SatForecast.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
507
main.py
Normal file
|
|
@ -0,0 +1,507 @@
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, messagebox
|
||||||
|
from PIL import Image, ImageTk
|
||||||
|
import requests
|
||||||
|
from io import BytesIO
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
def __init__(self, max_per_second=1):
|
||||||
|
self.min_interval = 1.0 / max_per_second
|
||||||
|
self.last_request = 0
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
elapsed = time.time() - self.last_request
|
||||||
|
if elapsed < self.min_interval:
|
||||||
|
time.sleep(self.min_interval - elapsed)
|
||||||
|
self.last_request = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherMapApp:
|
||||||
|
def __init__(self, root):
|
||||||
|
self.root = root
|
||||||
|
self.root.title("SatForecast")
|
||||||
|
self.root.geometry("1200x800")
|
||||||
|
|
||||||
|
# API ключи
|
||||||
|
self.owm_api_key = "cc437a23f6a217cd5859bd2d4dc9b6f8"
|
||||||
|
|
||||||
|
# Rate limiter
|
||||||
|
self.rate_limiter = RateLimiter(max_per_second=2)
|
||||||
|
|
||||||
|
# Настройки карты
|
||||||
|
self.lon = 37.6173
|
||||||
|
self.lat = 55.7558
|
||||||
|
self.zoom = 8
|
||||||
|
self.dragging = False
|
||||||
|
self.drag_start_x = 0
|
||||||
|
self.drag_start_y = 0
|
||||||
|
|
||||||
|
# Кэш тайлов
|
||||||
|
self.cache_dir = Path("tile_cache")
|
||||||
|
self.cache_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Маркеры
|
||||||
|
self.markers = [
|
||||||
|
{"name": "Москва", "lat": 55.7558, "lon": 37.6173},
|
||||||
|
{"name": "СПб", "lat": 59.9343, "lon": 30.3351},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Настройка интерфейса
|
||||||
|
self.setup_ui()
|
||||||
|
|
||||||
|
# Загрузка данных
|
||||||
|
self.load_map()
|
||||||
|
self.load_forecast()
|
||||||
|
|
||||||
|
def setup_ui(self):
|
||||||
|
# Основной контейнер
|
||||||
|
main_frame = ttk.Frame(self.root)
|
||||||
|
main_frame.pack(fill='both', expand=True)
|
||||||
|
|
||||||
|
# Левая панель управления
|
||||||
|
control_panel = ttk.Frame(main_frame, width=300)
|
||||||
|
control_panel.pack(side='left', fill='y', padx=5, pady=5)
|
||||||
|
control_panel.pack_propagate(False)
|
||||||
|
|
||||||
|
# Поиск города
|
||||||
|
ttk.Label(control_panel, text="Поиск города:").pack(pady=5)
|
||||||
|
search_frame = ttk.Frame(control_panel)
|
||||||
|
search_frame.pack(fill='x', padx=5)
|
||||||
|
|
||||||
|
self.city_entry = ttk.Entry(search_frame)
|
||||||
|
self.city_entry.pack(side='left', fill='x', expand=True)
|
||||||
|
self.city_entry.bind('<Return>', lambda e: self.search_city())
|
||||||
|
|
||||||
|
ttk.Button(search_frame, text="🔍", width=3,
|
||||||
|
command=self.search_city).pack(side='left')
|
||||||
|
|
||||||
|
# Список городов
|
||||||
|
ttk.Label(control_panel, text="Избранные города:").pack(pady=5)
|
||||||
|
self.city_listbox = tk.Listbox(control_panel, height=8)
|
||||||
|
self.city_listbox.pack(fill='x', padx=5)
|
||||||
|
self.city_listbox.bind('<<ListboxSelect>>', self.on_city_select)
|
||||||
|
|
||||||
|
for marker in self.markers:
|
||||||
|
self.city_listbox.insert(tk.END, marker['name'])
|
||||||
|
|
||||||
|
# Управление маркерами
|
||||||
|
marker_frame = ttk.Frame(control_panel)
|
||||||
|
marker_frame.pack(fill='x', padx=5, pady=5)
|
||||||
|
|
||||||
|
ttk.Button(marker_frame, text="Добавить маркер",
|
||||||
|
command=self.add_current_location).pack(fill='x')
|
||||||
|
ttk.Button(marker_frame, text="Удалить маркер",
|
||||||
|
command=self.remove_marker).pack(fill='x')
|
||||||
|
|
||||||
|
# Слои карты
|
||||||
|
ttk.Label(control_panel, text="Слой карты:").pack(pady=5)
|
||||||
|
|
||||||
|
layers = [
|
||||||
|
("Спутник (ESRI)", "esri_sat"),
|
||||||
|
("Спутник (Google)", "google_sat"),
|
||||||
|
("OpenStreetMap", "osm"),
|
||||||
|
("CartoDB", "cartodb"),
|
||||||
|
("Температура", "temp"),
|
||||||
|
("Облачность", "clouds"),
|
||||||
|
("Осадки", "precipitation")
|
||||||
|
]
|
||||||
|
|
||||||
|
self.layer_var = tk.StringVar(value="esri_sat")
|
||||||
|
for text, value in layers:
|
||||||
|
ttk.Radiobutton(control_panel, text=text, variable=self.layer_var,
|
||||||
|
value=value, command=self.load_map).pack(anchor='w', padx=20)
|
||||||
|
|
||||||
|
# Прогноз погоды
|
||||||
|
ttk.Label(control_panel, text="Прогноз погоды:").pack(pady=10)
|
||||||
|
self.forecast_text = tk.Text(control_panel, height=12, width=35)
|
||||||
|
self.forecast_text.pack(fill='both', expand=True, padx=5)
|
||||||
|
|
||||||
|
# Карта
|
||||||
|
map_frame = ttk.Frame(main_frame)
|
||||||
|
map_frame.pack(side='right', fill='both', expand=True)
|
||||||
|
|
||||||
|
self.canvas = tk.Canvas(map_frame, bg='#1a1a2e')
|
||||||
|
self.canvas.pack(fill='both', expand=True)
|
||||||
|
|
||||||
|
# Привязка событий
|
||||||
|
self.canvas.bind('<Button-1>', self.start_drag)
|
||||||
|
self.canvas.bind('<B1-Motion>', self.drag_map)
|
||||||
|
self.canvas.bind('<ButtonRelease-1>', self.end_drag)
|
||||||
|
self.canvas.bind('<MouseWheel>', self.mouse_zoom)
|
||||||
|
|
||||||
|
# Кнопки масштаба
|
||||||
|
zoom_frame = ttk.Frame(map_frame)
|
||||||
|
zoom_frame.place(relx=1.0, rely=0.5, anchor='e', x=-10)
|
||||||
|
|
||||||
|
ttk.Button(zoom_frame, text="+", width=3,
|
||||||
|
command=lambda: self.change_zoom(1)).pack()
|
||||||
|
ttk.Button(zoom_frame, text="-", width=3,
|
||||||
|
command=lambda: self.change_zoom(-1)).pack()
|
||||||
|
|
||||||
|
# Статус бар
|
||||||
|
self.status_label = ttk.Label(self.root, text="Готово")
|
||||||
|
self.status_label.pack(side='bottom', fill='x')
|
||||||
|
|
||||||
|
def get_cache_path(self, layer, x, y, zoom):
|
||||||
|
cache_subdir = self.cache_dir / layer / str(zoom) / str(x)
|
||||||
|
cache_subdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return cache_subdir / f"{y}.png"
|
||||||
|
|
||||||
|
def download_tile_cached(self, url, layer, x, y, zoom):
|
||||||
|
cache_path = self.get_cache_path(layer, x, y, zoom)
|
||||||
|
|
||||||
|
# Проверяем кэш
|
||||||
|
if cache_path.exists():
|
||||||
|
max_age = 3 * 3600 if "sat" in layer else 24 * 3600
|
||||||
|
if datetime.now().timestamp() - cache_path.stat().st_mtime < max_age:
|
||||||
|
try:
|
||||||
|
return Image.open(cache_path)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||||
|
'Referer': 'https://www.openstreetmap.org/',
|
||||||
|
'Sec-Fetch-Dest': 'image',
|
||||||
|
'Sec-Fetch-Mode': 'no-cors',
|
||||||
|
'Sec-Fetch-Site': 'cross-site',
|
||||||
|
}
|
||||||
|
|
||||||
|
self.rate_limiter.wait()
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, timeout=15)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
content_type = response.headers.get('content-type', '')
|
||||||
|
if 'image' in content_type or len(response.content) > 1000:
|
||||||
|
img = Image.open(BytesIO(response.content))
|
||||||
|
img.save(cache_path)
|
||||||
|
print(f"✓ {layer}: {x},{y} zoom={zoom}")
|
||||||
|
return img
|
||||||
|
else:
|
||||||
|
print(f"✗ Не изображение: {url[:80]}")
|
||||||
|
else:
|
||||||
|
print(f"✗ {response.status_code}: {url[:80]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Ошибка: {e}")
|
||||||
|
|
||||||
|
# Возвращаем из кэша
|
||||||
|
if cache_path.exists():
|
||||||
|
try:
|
||||||
|
return Image.open(cache_path)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_tile_url(self, x, y, zoom):
|
||||||
|
"""Рабочие бесплатные URL для тайлов"""
|
||||||
|
layer = self.layer_var.get()
|
||||||
|
|
||||||
|
if layer == "esri_sat":
|
||||||
|
# ESRI спутниковые снимки (бесплатно, без ключа)
|
||||||
|
return f"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{zoom}/{y}/{x}"
|
||||||
|
|
||||||
|
elif layer == "google_sat":
|
||||||
|
# Google Maps спутник (бесплатно для некоммерческого использования)
|
||||||
|
# Используем разные поддомены для распределения нагрузки
|
||||||
|
subdomain = random.choice(['0', '1', '2', '3'])
|
||||||
|
return f"https://mt{subdomain}.google.com/vt/lyrs=s&x={x}&y={y}&z={zoom}"
|
||||||
|
|
||||||
|
elif layer == "osm":
|
||||||
|
# OpenStreetMap
|
||||||
|
subdomain = random.choice(['a', 'b', 'c'])
|
||||||
|
return f"https://{subdomain}.tile.openstreetmap.org/{zoom}/{x}/{y}.png"
|
||||||
|
|
||||||
|
elif layer == "cartodb":
|
||||||
|
# CartoDB Light (красивый стиль)
|
||||||
|
return f"https://a.basemaps.cartocdn.com/light_all/{zoom}/{x}/{y}.png"
|
||||||
|
|
||||||
|
elif layer == "temp":
|
||||||
|
return f"https://tile.openweathermap.org/map/temp_new/{zoom}/{x}/{y}.png?appid={self.owm_api_key}"
|
||||||
|
|
||||||
|
elif layer == "clouds":
|
||||||
|
return f"https://tile.openweathermap.org/map/clouds_new/{zoom}/{x}/{y}.png?appid={self.owm_api_key}"
|
||||||
|
|
||||||
|
elif layer == "precipitation":
|
||||||
|
return f"https://tile.openweathermap.org/map/precipitation_new/{zoom}/{x}/{y}.png?appid={self.owm_api_key}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def lat_lon_to_tile(self, lat, lon, zoom):
|
||||||
|
lat_rad = math.radians(lat)
|
||||||
|
n = 2.0 ** zoom
|
||||||
|
xtile = (lon + 180.0) / 360.0 * n
|
||||||
|
ytile = (1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n
|
||||||
|
return xtile, ytile
|
||||||
|
|
||||||
|
def load_map(self):
|
||||||
|
threading.Thread(target=self._load_map_thread, daemon=True).start()
|
||||||
|
|
||||||
|
def _load_map_thread(self):
|
||||||
|
self.root.after(0, lambda: self.status_label.config(text="Загрузка карты..."))
|
||||||
|
|
||||||
|
self.canvas.delete("tile")
|
||||||
|
|
||||||
|
center_x, center_y = self.lat_lon_to_tile(self.lat, self.lon, self.zoom)
|
||||||
|
center_x_int = int(center_x)
|
||||||
|
center_y_int = int(center_y)
|
||||||
|
|
||||||
|
tiles = {}
|
||||||
|
for dx in range(-1, 2):
|
||||||
|
for dy in range(-1, 2):
|
||||||
|
x = center_x_int + dx
|
||||||
|
y = center_y_int + dy
|
||||||
|
|
||||||
|
url = self.get_tile_url(x, y, self.zoom)
|
||||||
|
if url:
|
||||||
|
layer = self.layer_var.get()
|
||||||
|
img = self.download_tile_cached(url, layer, x, y, self.zoom)
|
||||||
|
|
||||||
|
if img:
|
||||||
|
tiles[(dx, dy)] = ImageTk.PhotoImage(img)
|
||||||
|
|
||||||
|
self.root.after(0, self._display_tiles, tiles, center_x, center_y)
|
||||||
|
self.root.after(0, lambda: self.status_label.config(
|
||||||
|
text=f"Загружено тайлов: {len(tiles)}/9"))
|
||||||
|
|
||||||
|
def _display_tiles(self, tiles, center_x, center_y):
|
||||||
|
canvas_width = self.canvas.winfo_width()
|
||||||
|
canvas_height = self.canvas.winfo_height()
|
||||||
|
|
||||||
|
if canvas_width < 100:
|
||||||
|
canvas_width = 800
|
||||||
|
canvas_height = 600
|
||||||
|
|
||||||
|
tile_size = 256
|
||||||
|
|
||||||
|
for (dx, dy), photo in tiles.items():
|
||||||
|
x_offset = (center_x - int(center_x)) * tile_size
|
||||||
|
y_offset = (center_y - int(center_y)) * tile_size
|
||||||
|
|
||||||
|
px = canvas_width / 2 + dx * tile_size - x_offset
|
||||||
|
py = canvas_height / 2 + dy * tile_size - y_offset
|
||||||
|
|
||||||
|
self.canvas.create_image(px, py, image=photo, anchor='nw', tags="tile")
|
||||||
|
|
||||||
|
self.draw_markers()
|
||||||
|
self.canvas.tile_images = tiles
|
||||||
|
|
||||||
|
def draw_markers(self):
|
||||||
|
self.canvas.delete("marker")
|
||||||
|
|
||||||
|
canvas_width = self.canvas.winfo_width()
|
||||||
|
canvas_height = self.canvas.winfo_height()
|
||||||
|
|
||||||
|
for marker in self.markers:
|
||||||
|
marker_x, marker_y = self.lat_lon_to_tile(
|
||||||
|
marker['lat'], marker['lon'], self.zoom)
|
||||||
|
|
||||||
|
center_x, center_y = self.lat_lon_to_tile(self.lat, self.lon, self.zoom)
|
||||||
|
|
||||||
|
tile_size = 256
|
||||||
|
px = canvas_width / 2 + (marker_x - center_x) * tile_size
|
||||||
|
py = canvas_height / 2 + (marker_y - center_y) * tile_size
|
||||||
|
|
||||||
|
# Красивый маркер
|
||||||
|
self.canvas.create_oval(px - 10, py - 10, px + 10, py + 10,
|
||||||
|
fill='#ff4444', outline='white', width=2, tags="marker")
|
||||||
|
self.canvas.create_oval(px - 3, py - 3, px + 3, py + 3,
|
||||||
|
fill='white', tags="marker")
|
||||||
|
self.canvas.create_text(px, py - 20, text=marker['name'],
|
||||||
|
fill='white', font=('Arial', 10, 'bold'),
|
||||||
|
tags="marker")
|
||||||
|
|
||||||
|
def start_drag(self, event):
|
||||||
|
self.dragging = True
|
||||||
|
self.drag_start_x = event.x
|
||||||
|
self.drag_start_y = event.y
|
||||||
|
|
||||||
|
def drag_map(self, event):
|
||||||
|
if not self.dragging:
|
||||||
|
return
|
||||||
|
|
||||||
|
dx = event.x - self.drag_start_x
|
||||||
|
dy = event.y - self.drag_start_y
|
||||||
|
|
||||||
|
self.canvas.move("tile", dx, dy)
|
||||||
|
self.canvas.move("marker", dx, dy)
|
||||||
|
|
||||||
|
tile_size = 256
|
||||||
|
n = 2.0 ** self.zoom
|
||||||
|
|
||||||
|
self.lon -= dx / tile_size * 360.0 / n
|
||||||
|
self.lat += dy / tile_size * 180.0 / n
|
||||||
|
|
||||||
|
self.drag_start_x = event.x
|
||||||
|
self.drag_start_y = event.y
|
||||||
|
|
||||||
|
if abs(dx) > tile_size / 2 or abs(dy) > tile_size / 2:
|
||||||
|
self.load_map()
|
||||||
|
|
||||||
|
def end_drag(self, event):
|
||||||
|
self.dragging = False
|
||||||
|
self.load_map()
|
||||||
|
|
||||||
|
def mouse_zoom(self, event):
|
||||||
|
if event.delta > 0:
|
||||||
|
self.change_zoom(1)
|
||||||
|
else:
|
||||||
|
self.change_zoom(-1)
|
||||||
|
|
||||||
|
def change_zoom(self, delta):
|
||||||
|
new_zoom = self.zoom + delta
|
||||||
|
if 2 <= new_zoom <= 18:
|
||||||
|
self.zoom = new_zoom
|
||||||
|
self.load_map()
|
||||||
|
|
||||||
|
def search_city(self):
|
||||||
|
city = self.city_entry.get().strip()
|
||||||
|
if not city:
|
||||||
|
return
|
||||||
|
threading.Thread(target=self._search_city_thread, args=(city,), daemon=True).start()
|
||||||
|
|
||||||
|
def _search_city_thread(self, city):
|
||||||
|
try:
|
||||||
|
url = "https://nominatim.openstreetmap.org/search"
|
||||||
|
params = {
|
||||||
|
"q": city,
|
||||||
|
"format": "json",
|
||||||
|
"limit": 1
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "SatForecast/1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(url, params=params, headers=headers)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data:
|
||||||
|
lat = float(data[0]['lat'])
|
||||||
|
lon = float(data[0]['lon'])
|
||||||
|
self.root.after(0, lambda: self.set_location(lat, lon, city))
|
||||||
|
else:
|
||||||
|
self.root.after(0, lambda: messagebox.showinfo("Поиск", "Город не найден"))
|
||||||
|
except Exception as e:
|
||||||
|
self.root.after(0, lambda: messagebox.showerror("Ошибка", str(e)))
|
||||||
|
|
||||||
|
def set_location(self, lat, lon, name):
|
||||||
|
self.lat = lat
|
||||||
|
self.lon = lon
|
||||||
|
|
||||||
|
if not any(m['name'] == name for m in self.markers):
|
||||||
|
self.markers.append({"name": name, "lat": lat, "lon": lon})
|
||||||
|
self.city_listbox.insert(tk.END, name)
|
||||||
|
|
||||||
|
self.load_map()
|
||||||
|
self.load_forecast()
|
||||||
|
|
||||||
|
def on_city_select(self, event):
|
||||||
|
selection = self.city_listbox.curselection()
|
||||||
|
if selection:
|
||||||
|
index = selection[0]
|
||||||
|
if index < len(self.markers):
|
||||||
|
marker = self.markers[index]
|
||||||
|
self.set_location(marker['lat'], marker['lon'], marker['name'])
|
||||||
|
|
||||||
|
def add_current_location(self):
|
||||||
|
name = f"Точка {len(self.markers) + 1}"
|
||||||
|
self.markers.append({"name": name, "lat": self.lat, "lon": self.lon})
|
||||||
|
self.city_listbox.insert(tk.END, name)
|
||||||
|
self.draw_markers()
|
||||||
|
|
||||||
|
def remove_marker(self):
|
||||||
|
selection = self.city_listbox.curselection()
|
||||||
|
if selection:
|
||||||
|
index = selection[0]
|
||||||
|
if index < len(self.markers):
|
||||||
|
del self.markers[index]
|
||||||
|
self.city_listbox.delete(index)
|
||||||
|
self.load_map()
|
||||||
|
|
||||||
|
def load_forecast(self):
|
||||||
|
threading.Thread(target=self._load_forecast_thread, daemon=True).start()
|
||||||
|
|
||||||
|
def _load_forecast_thread(self):
|
||||||
|
try:
|
||||||
|
# Open-Meteo (бесплатно, без ключа)
|
||||||
|
url = "https://api.open-meteo.com/v1/forecast"
|
||||||
|
params = {
|
||||||
|
"latitude": self.lat,
|
||||||
|
"longitude": self.lon,
|
||||||
|
"hourly": "temperature_2m,precipitation,wind_speed_10m,cloud_cover",
|
||||||
|
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
|
||||||
|
"timezone": "auto",
|
||||||
|
"forecast_days": 3
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(url, params=params)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
forecast_text = f"📍 Прогноз для координат:\n{self.lat:.2f}, {self.lon:.2f}\n\n"
|
||||||
|
|
||||||
|
if 'daily' in data:
|
||||||
|
forecast_text += "📅 По дням:\n"
|
||||||
|
for i in range(3):
|
||||||
|
date = data['daily']['time'][i]
|
||||||
|
temp_max = data['daily']['temperature_2m_max'][i]
|
||||||
|
temp_min = data['daily']['temperature_2m_min'][i]
|
||||||
|
precip = data['daily']['precipitation_sum'][i]
|
||||||
|
|
||||||
|
weather_emoji = "☀️" if precip == 0 else "🌧️"
|
||||||
|
if temp_max > 30:
|
||||||
|
weather_emoji = "🔥"
|
||||||
|
elif temp_max < 0:
|
||||||
|
weather_emoji = "❄️"
|
||||||
|
|
||||||
|
forecast_text += f"{date}: {weather_emoji} {temp_min}°C...{temp_max}°C, 💧{precip}мм\n"
|
||||||
|
|
||||||
|
if 'hourly' in data:
|
||||||
|
forecast_text += "\n🕐 Ближайшие часы:\n"
|
||||||
|
current_hour = datetime.now().hour
|
||||||
|
for i in range(current_hour, min(current_hour + 8, len(data['hourly']['time']))):
|
||||||
|
time = data['hourly']['time'][i].split('T')[1]
|
||||||
|
temp = data['hourly']['temperature_2m'][i]
|
||||||
|
wind = data['hourly']['wind_speed_10m'][i]
|
||||||
|
clouds = data['hourly']['cloud_cover'][i]
|
||||||
|
|
||||||
|
cloud_emoji = "☁️" if clouds > 50 else "🌤️" if clouds > 20 else "☀️"
|
||||||
|
forecast_text += f"{time}: {cloud_emoji} {temp}°C, 💨{wind}м/с\n"
|
||||||
|
|
||||||
|
self.root.after(0, self._update_forecast_display, forecast_text)
|
||||||
|
except Exception as e:
|
||||||
|
self.root.after(0, lambda: self.status_label.config(text=f"Ошибка прогноза: {e}"))
|
||||||
|
|
||||||
|
def _update_forecast_display(self, text):
|
||||||
|
self.forecast_text.delete(1.0, tk.END)
|
||||||
|
self.forecast_text.insert(1.0, text)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root = tk.Tk()
|
||||||
|
app = WeatherMapApp(root)
|
||||||
|
|
||||||
|
style = ttk.Style()
|
||||||
|
style.theme_use('clam')
|
||||||
|
|
||||||
|
def on_closing():
|
||||||
|
if messagebox.askokcancel("Выход", "Закрыть приложение?"):
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
root.protocol("WM_DELETE_WINDOW", on_closing)
|
||||||
|
root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
tile_cache/cartodb/9/311/178.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tile_cache/cartodb/9/311/179.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
tile_cache/cartodb/9/311/180.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
tile_cache/cartodb/9/312/178.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
tile_cache/cartodb/9/312/179.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
tile_cache/cartodb/9/312/180.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
tile_cache/cartodb/9/313/178.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
tile_cache/cartodb/9/313/179.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
tile_cache/cartodb/9/313/180.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
tile_cache/clouds/8/155/88.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
tile_cache/clouds/8/155/89.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tile_cache/clouds/8/155/90.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tile_cache/clouds/8/156/88.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
tile_cache/clouds/8/156/89.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
tile_cache/clouds/8/156/90.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
tile_cache/clouds/8/157/88.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
tile_cache/clouds/8/157/89.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
tile_cache/clouds/8/157/90.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tile_cache/esri_sat/10/623/358.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
tile_cache/esri_sat/10/623/359.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
tile_cache/esri_sat/10/623/360.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
tile_cache/esri_sat/10/624/358.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
tile_cache/esri_sat/10/624/359.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
tile_cache/esri_sat/10/624/360.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
tile_cache/esri_sat/10/625/358.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
tile_cache/esri_sat/10/625/359.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
tile_cache/esri_sat/10/625/360.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
tile_cache/esri_sat/11/1248/717.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
tile_cache/esri_sat/11/1248/718.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
tile_cache/esri_sat/11/1248/719.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
tile_cache/esri_sat/11/1249/717.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
tile_cache/esri_sat/12/1249/718.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/12/2498/1435.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
tile_cache/esri_sat/13/1249/719.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/13/1250/717.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/13/1250/718.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/13/1250/719.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/13/2498/1436.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/13/2498/1437.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/13/2499/1435.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/13/2499/1436.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/13/2499/1437.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/13/2500/1435.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/13/2500/1436.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/13/2500/1437.png
Normal file
|
After Width: | Height: | Size: 762 B |
BIN
tile_cache/esri_sat/13/4995/2871.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
tile_cache/esri_sat/13/4995/2872.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
tile_cache/esri_sat/13/4995/2873.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
tile_cache/esri_sat/13/4996/2871.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
tile_cache/esri_sat/13/4996/2872.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
tile_cache/esri_sat/13/4997/2871.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
tile_cache/esri_sat/13/4997/2872.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
tile_cache/esri_sat/13/4997/2873.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
tile_cache/esri_sat/13/4997/2874.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
tile_cache/esri_sat/13/4998/2871.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
tile_cache/esri_sat/13/4998/2872.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
tile_cache/esri_sat/13/4998/2873.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
tile_cache/esri_sat/13/4998/2874.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
tile_cache/esri_sat/13/4999/2871.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
tile_cache/esri_sat/13/4999/2872.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
tile_cache/esri_sat/13/4999/2873.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
tile_cache/esri_sat/13/4999/2874.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
tile_cache/esri_sat/13/5000/2872.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
tile_cache/esri_sat/13/5000/2873.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
tile_cache/esri_sat/13/5000/2874.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
tile_cache/esri_sat/14/19988/11491.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
tile_cache/esri_sat/14/9991/5743.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
tile_cache/esri_sat/14/9991/5744.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
tile_cache/esri_sat/14/9991/5745.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
tile_cache/esri_sat/14/9992/5742.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
tile_cache/esri_sat/14/9992/5743.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
tile_cache/esri_sat/14/9992/5744.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
tile_cache/esri_sat/14/9992/5745.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
tile_cache/esri_sat/14/9992/5746.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
tile_cache/esri_sat/14/9993/5742.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
tile_cache/esri_sat/14/9993/5743.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
tile_cache/esri_sat/14/9993/5744.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
tile_cache/esri_sat/14/9993/5745.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
tile_cache/esri_sat/14/9993/5746.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
tile_cache/esri_sat/14/9994/5742.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
tile_cache/esri_sat/14/9994/5743.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
tile_cache/esri_sat/14/9994/5744.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
tile_cache/esri_sat/14/9994/5745.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
tile_cache/esri_sat/14/9994/5746.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
tile_cache/esri_sat/15/19986/11489.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
tile_cache/esri_sat/15/19986/11490.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
tile_cache/esri_sat/15/19986/11491.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
tile_cache/esri_sat/15/19987/11489.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
tile_cache/esri_sat/15/19987/11490.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
tile_cache/esri_sat/15/19987/11491.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
tile_cache/esri_sat/15/19988/11489.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
tile_cache/esri_sat/15/19988/11490.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
tile_cache/esri_sat/15/4996/2873.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |