507 lines
No EOL
18 KiB
Python
507 lines
No EOL
18 KiB
Python
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() |