Compare commits
No commits in common. "72c09673149d53b34d4c1b8e352cece76ec16377" and "2053629b9d162918d82f67feae97927c5d1fa9a9" have entirely different histories.
72c0967314
...
2053629b9d
10
.idea/.gitignore
generated
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
# 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
|
|
@ -1,8 +0,0 @@
|
||||||
<?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
|
|
@ -1,6 +0,0 @@
|
||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<settings>
|
|
||||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
|
||||||
<version value="1.0" />
|
|
||||||
</settings>
|
|
||||||
</component>
|
|
||||||
7
.idea/misc.xml
generated
|
|
@ -1,7 +0,0 @@
|
||||||
<?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
|
|
@ -1,8 +0,0 @@
|
||||||
<?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
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
507
main.py
|
|
@ -1,507 +0,0 @@
|
||||||
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()
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |