working prototype

This commit is contained in:
Vic Sergeev 2026-06-12 12:44:27 +03:00
parent 2053629b9d
commit f588eacdfc
215 changed files with 552 additions and 0 deletions

507
main.py Normal file
View 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()