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('', 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('<>', 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('', self.start_drag) self.canvas.bind('', self.drag_map) self.canvas.bind('', self.end_drag) self.canvas.bind('', 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()