initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Flask Weather Website
|
||||||
|
|
||||||
|
Small Flask app that geocodes a location and shows current conditions plus a 5-day forecast using Open-Meteo.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- Forecast: https://api.open-meteo.com/v1/forecast
|
||||||
|
- Geocoding: https://geocoding-api.open-meteo.com/v1/search
|
||||||
|
- No API key required for basic, non-commercial usage.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
python3 -m pip install -r requirements.txt
|
||||||
|
python3 app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://127.0.0.1:5000.
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from flask import Flask, render_template, request
|
||||||
|
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
DEFAULT_LOCATION = "Seattle"
|
||||||
|
GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||||
|
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
|
||||||
|
USER_AGENT = "SETI-Flask-Weather-Demo/1.0"
|
||||||
|
|
||||||
|
CURRENT_FIELDS = [
|
||||||
|
"temperature_2m",
|
||||||
|
"relative_humidity_2m",
|
||||||
|
"apparent_temperature",
|
||||||
|
"is_day",
|
||||||
|
"precipitation",
|
||||||
|
"weather_code",
|
||||||
|
"cloud_cover",
|
||||||
|
"wind_speed_10m",
|
||||||
|
"wind_direction_10m",
|
||||||
|
"wind_gusts_10m",
|
||||||
|
]
|
||||||
|
|
||||||
|
DAILY_FIELDS = [
|
||||||
|
"weather_code",
|
||||||
|
"temperature_2m_max",
|
||||||
|
"temperature_2m_min",
|
||||||
|
"precipitation_probability_max",
|
||||||
|
"precipitation_sum",
|
||||||
|
"wind_speed_10m_max",
|
||||||
|
]
|
||||||
|
|
||||||
|
WEATHER_CODES = {
|
||||||
|
0: "Clear sky",
|
||||||
|
1: "Mainly clear",
|
||||||
|
2: "Partly cloudy",
|
||||||
|
3: "Overcast",
|
||||||
|
45: "Fog",
|
||||||
|
48: "Depositing rime fog",
|
||||||
|
51: "Light drizzle",
|
||||||
|
53: "Drizzle",
|
||||||
|
55: "Heavy drizzle",
|
||||||
|
56: "Light freezing drizzle",
|
||||||
|
57: "Freezing drizzle",
|
||||||
|
61: "Light rain",
|
||||||
|
63: "Rain",
|
||||||
|
65: "Heavy rain",
|
||||||
|
66: "Light freezing rain",
|
||||||
|
67: "Freezing rain",
|
||||||
|
71: "Light snow",
|
||||||
|
73: "Snow",
|
||||||
|
75: "Heavy snow",
|
||||||
|
77: "Snow grains",
|
||||||
|
80: "Light rain showers",
|
||||||
|
81: "Rain showers",
|
||||||
|
82: "Heavy rain showers",
|
||||||
|
85: "Light snow showers",
|
||||||
|
86: "Snow showers",
|
||||||
|
95: "Thunderstorm",
|
||||||
|
96: "Thunderstorm with hail",
|
||||||
|
99: "Severe thunderstorm with hail",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherServiceError(Exception):
|
||||||
|
"""Raised when the weather or geocoding API cannot return usable data."""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_json(url, params):
|
||||||
|
request_url = f"{url}?{urlencode(params)}"
|
||||||
|
api_request = Request(request_url, headers={"User-Agent": USER_AGENT})
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urlopen(api_request, timeout=10) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
except HTTPError as exc:
|
||||||
|
raise WeatherServiceError(f"Weather API returned HTTP {exc.code}.") from exc
|
||||||
|
except (TimeoutError, URLError) as exc:
|
||||||
|
raise WeatherServiceError("Weather API is unreachable right now.") from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise WeatherServiceError("Weather API returned invalid JSON.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def geocode_location(query):
|
||||||
|
cleaned_query = (query or DEFAULT_LOCATION).strip() or DEFAULT_LOCATION
|
||||||
|
search_terms = [cleaned_query]
|
||||||
|
if "," in cleaned_query:
|
||||||
|
search_terms.append(cleaned_query.split(",", 1)[0].strip())
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for search_term in search_terms:
|
||||||
|
if not search_term:
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = fetch_json(
|
||||||
|
GEOCODE_URL,
|
||||||
|
{
|
||||||
|
"name": search_term,
|
||||||
|
"count": 1,
|
||||||
|
"language": "en",
|
||||||
|
"format": "json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
results = payload.get("results") or []
|
||||||
|
if results:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
raise WeatherServiceError(f"No location found for '{cleaned_query}'.")
|
||||||
|
|
||||||
|
result = results[0]
|
||||||
|
display_parts = [
|
||||||
|
result.get("name"),
|
||||||
|
result.get("admin1"),
|
||||||
|
result.get("country"),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": result["name"],
|
||||||
|
"display": ", ".join(part for part in display_parts if part),
|
||||||
|
"latitude": result["latitude"],
|
||||||
|
"longitude": result["longitude"],
|
||||||
|
"timezone": result.get("timezone", "auto"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_forecast(location):
|
||||||
|
payload = fetch_json(
|
||||||
|
FORECAST_URL,
|
||||||
|
{
|
||||||
|
"latitude": location["latitude"],
|
||||||
|
"longitude": location["longitude"],
|
||||||
|
"current": ",".join(CURRENT_FIELDS),
|
||||||
|
"daily": ",".join(DAILY_FIELDS),
|
||||||
|
"temperature_unit": "fahrenheit",
|
||||||
|
"wind_speed_unit": "mph",
|
||||||
|
"precipitation_unit": "inch",
|
||||||
|
"forecast_days": 5,
|
||||||
|
"timezone": "auto",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return normalize_forecast(location, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_forecast(location, payload):
|
||||||
|
current = payload.get("current") or {}
|
||||||
|
daily = payload.get("daily") or {}
|
||||||
|
current_code = current.get("weather_code")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"location": location,
|
||||||
|
"current": {
|
||||||
|
"time": format_timestamp(current.get("time")),
|
||||||
|
"temperature": format_number(current.get("temperature_2m"), "F"),
|
||||||
|
"apparent_temperature": format_number(
|
||||||
|
current.get("apparent_temperature"),
|
||||||
|
"F",
|
||||||
|
),
|
||||||
|
"humidity": format_number(current.get("relative_humidity_2m"), "%"),
|
||||||
|
"precipitation": format_number(
|
||||||
|
current.get("precipitation"),
|
||||||
|
" in",
|
||||||
|
digits=2,
|
||||||
|
),
|
||||||
|
"cloud_cover": format_number(current.get("cloud_cover"), "%"),
|
||||||
|
"wind": format_wind(
|
||||||
|
current.get("wind_speed_10m"),
|
||||||
|
current.get("wind_direction_10m"),
|
||||||
|
),
|
||||||
|
"wind_gusts": format_number(current.get("wind_gusts_10m"), " mph"),
|
||||||
|
"label": weather_label(current_code),
|
||||||
|
"icon": weather_icon(current_code),
|
||||||
|
},
|
||||||
|
"days": normalize_daily(daily),
|
||||||
|
"generation_ms": round(payload.get("generationtime_ms", 0), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_daily(daily):
|
||||||
|
days = []
|
||||||
|
dates = daily.get("time") or []
|
||||||
|
|
||||||
|
for index, date_value in enumerate(dates):
|
||||||
|
weather_code = daily_value(daily, "weather_code", index)
|
||||||
|
days.append(
|
||||||
|
{
|
||||||
|
"date": format_date(date_value),
|
||||||
|
"label": weather_label(weather_code),
|
||||||
|
"icon": weather_icon(weather_code),
|
||||||
|
"high": format_number(
|
||||||
|
daily_value(daily, "temperature_2m_max", index),
|
||||||
|
"F",
|
||||||
|
),
|
||||||
|
"low": format_number(
|
||||||
|
daily_value(daily, "temperature_2m_min", index),
|
||||||
|
"F",
|
||||||
|
),
|
||||||
|
"precip_probability": format_number(
|
||||||
|
daily_value(daily, "precipitation_probability_max", index),
|
||||||
|
"%",
|
||||||
|
),
|
||||||
|
"precip_sum": format_number(
|
||||||
|
daily_value(daily, "precipitation_sum", index),
|
||||||
|
" in",
|
||||||
|
digits=2,
|
||||||
|
),
|
||||||
|
"wind": format_number(
|
||||||
|
daily_value(daily, "wind_speed_10m_max", index),
|
||||||
|
" mph",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
def daily_value(daily, field, index):
|
||||||
|
values = daily.get(field) or []
|
||||||
|
if index >= len(values):
|
||||||
|
return None
|
||||||
|
return values[index]
|
||||||
|
|
||||||
|
|
||||||
|
def weather_label(code):
|
||||||
|
return WEATHER_CODES.get(code, "Forecast unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
def weather_icon(code):
|
||||||
|
if code in {0, 1}:
|
||||||
|
return "sun"
|
||||||
|
if code in {2, 3, 45, 48}:
|
||||||
|
return "cloud"
|
||||||
|
if code in {51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82}:
|
||||||
|
return "rain"
|
||||||
|
if code in {71, 73, 75, 77, 85, 86}:
|
||||||
|
return "snow"
|
||||||
|
if code in {95, 96, 99}:
|
||||||
|
return "storm"
|
||||||
|
return "cloud"
|
||||||
|
|
||||||
|
|
||||||
|
def format_number(value, suffix="", digits=0):
|
||||||
|
if value is None:
|
||||||
|
return "--"
|
||||||
|
rounded = round(value, digits)
|
||||||
|
if digits == 0:
|
||||||
|
rounded = int(rounded)
|
||||||
|
return f"{rounded}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_wind(speed, direction):
|
||||||
|
if speed is None:
|
||||||
|
return "--"
|
||||||
|
return f"{format_number(speed, ' mph')} {compass_direction(direction)}"
|
||||||
|
|
||||||
|
|
||||||
|
def compass_direction(degrees):
|
||||||
|
if degrees is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
directions = [
|
||||||
|
"N",
|
||||||
|
"NNE",
|
||||||
|
"NE",
|
||||||
|
"ENE",
|
||||||
|
"E",
|
||||||
|
"ESE",
|
||||||
|
"SE",
|
||||||
|
"SSE",
|
||||||
|
"S",
|
||||||
|
"SSW",
|
||||||
|
"SW",
|
||||||
|
"WSW",
|
||||||
|
"W",
|
||||||
|
"WNW",
|
||||||
|
"NW",
|
||||||
|
"NNW",
|
||||||
|
]
|
||||||
|
index = round(degrees / 22.5) % 16
|
||||||
|
return directions[index]
|
||||||
|
|
||||||
|
|
||||||
|
def format_timestamp(value):
|
||||||
|
if not value:
|
||||||
|
return "Latest model run"
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
return parsed.strftime("%b %-d, %-I:%M %p")
|
||||||
|
|
||||||
|
|
||||||
|
def format_date(value):
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
return parsed.strftime("%a, %b %-d")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
query = request.args.get("q", DEFAULT_LOCATION)
|
||||||
|
forecast = None
|
||||||
|
error = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
location = geocode_location(query)
|
||||||
|
forecast = get_forecast(location)
|
||||||
|
except WeatherServiceError as exc:
|
||||||
|
error = str(exc)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
default_location=DEFAULT_LOCATION,
|
||||||
|
error=error,
|
||||||
|
forecast=forecast,
|
||||||
|
query=query,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="127.0.0.1", port=5000, debug=True)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Flask>=3.0,<4.0
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--background: #f4f6f8;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--ink: #172033;
|
||||||
|
--muted: #667085;
|
||||||
|
--line: #d8dee7;
|
||||||
|
--teal: #00796b;
|
||||||
|
--teal-dark: #00564d;
|
||||||
|
--amber: #f2a413;
|
||||||
|
--coral: #d45b45;
|
||||||
|
--rain: #3d7ea6;
|
||||||
|
--snow: #7b8794;
|
||||||
|
--shadow: 0 16px 40px rgb(23 32 51 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #f4f6f8 0%, #eef3f1 48%, #f8f4f0 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
width: min(1120px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar,
|
||||||
|
.current-panel,
|
||||||
|
.day-card,
|
||||||
|
.alert {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgb(255 255 255 / 86%);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: var(--teal-dark);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: clamp(1.35rem, 2.4vw, 2.2rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
width: min(420px, 100%);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input,
|
||||||
|
.search button {
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 0 14px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search button {
|
||||||
|
border: 0;
|
||||||
|
padding: 0 18px;
|
||||||
|
background: var(--teal);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search button:hover,
|
||||||
|
.search button:focus-visible {
|
||||||
|
background: var(--teal-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
color: #762315;
|
||||||
|
background: #fff4f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 0.9fr) minmax(360px, 1.1fr);
|
||||||
|
gap: 22px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 22px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-sun {
|
||||||
|
border-color: rgb(242 164 19 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-cloud {
|
||||||
|
border-color: rgb(123 135 148 / 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-rain,
|
||||||
|
.condition-storm {
|
||||||
|
border-color: rgb(61 126 166 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-snow {
|
||||||
|
border-color: rgb(123 135 148 / 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 22px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-summary h2 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: clamp(3.2rem, 8vw, 5.8rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics,
|
||||||
|
.day-metrics {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics div,
|
||||||
|
.day-metrics div {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #e7ebf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-card {
|
||||||
|
min-height: 255px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header h2 {
|
||||||
|
margin-bottom: 3px;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-metrics {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-metrics div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-metrics dd {
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-art,
|
||||||
|
.mini-art {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-art {
|
||||||
|
width: 118px;
|
||||||
|
height: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-art {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-art span,
|
||||||
|
.mini-art span,
|
||||||
|
.weather-art::before,
|
||||||
|
.mini-art::before,
|
||||||
|
.weather-art::after,
|
||||||
|
.mini-art::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun,
|
||||||
|
.mini-art.sun {
|
||||||
|
background: rgb(242 164 19 / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sun span,
|
||||||
|
.mini-art.sun span {
|
||||||
|
width: 48%;
|
||||||
|
height: 48%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--amber);
|
||||||
|
box-shadow: 0 0 0 10px rgb(242 164 19 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud,
|
||||||
|
.mini-art.cloud {
|
||||||
|
background: rgb(123 135 148 / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud span,
|
||||||
|
.rain span,
|
||||||
|
.snow span,
|
||||||
|
.storm span,
|
||||||
|
.mini-art.cloud span,
|
||||||
|
.mini-art.rain span,
|
||||||
|
.mini-art.snow span,
|
||||||
|
.mini-art.storm span {
|
||||||
|
width: 58%;
|
||||||
|
height: 28%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #7b8794;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud span::before,
|
||||||
|
.rain span::before,
|
||||||
|
.snow span::before,
|
||||||
|
.storm span::before,
|
||||||
|
.mini-art.cloud span::before,
|
||||||
|
.mini-art.rain span::before,
|
||||||
|
.mini-art.snow span::before,
|
||||||
|
.mini-art.storm span::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 12%;
|
||||||
|
bottom: 35%;
|
||||||
|
width: 34%;
|
||||||
|
height: 85%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud span::after,
|
||||||
|
.rain span::after,
|
||||||
|
.snow span::after,
|
||||||
|
.storm span::after,
|
||||||
|
.mini-art.cloud span::after,
|
||||||
|
.mini-art.rain span::after,
|
||||||
|
.mini-art.snow span::after,
|
||||||
|
.mini-art.storm span::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 10%;
|
||||||
|
bottom: 26%;
|
||||||
|
width: 42%;
|
||||||
|
height: 110%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rain,
|
||||||
|
.mini-art.rain {
|
||||||
|
background: rgb(61 126 166 / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rain span,
|
||||||
|
.mini-art.rain span {
|
||||||
|
background: var(--rain);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rain::after,
|
||||||
|
.mini-art.rain::after {
|
||||||
|
width: 38%;
|
||||||
|
height: 26%;
|
||||||
|
border-right: 3px solid var(--rain);
|
||||||
|
border-left: 3px solid var(--rain);
|
||||||
|
transform: translateY(75%) skewX(-15deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snow,
|
||||||
|
.mini-art.snow {
|
||||||
|
background: rgb(123 135 148 / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snow span,
|
||||||
|
.mini-art.snow span {
|
||||||
|
background: var(--snow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snow::after,
|
||||||
|
.mini-art.snow::after {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #aeb8c4;
|
||||||
|
box-shadow: -14px 4px 0 #aeb8c4, 14px 4px 0 #aeb8c4;
|
||||||
|
transform: translateY(95%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storm,
|
||||||
|
.mini-art.storm {
|
||||||
|
background: rgb(212 91 69 / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storm span,
|
||||||
|
.mini-art.storm span {
|
||||||
|
background: var(--coral);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storm::after,
|
||||||
|
.mini-art.storm::after {
|
||||||
|
width: 14%;
|
||||||
|
height: 32%;
|
||||||
|
background: var(--amber);
|
||||||
|
clip-path: polygon(45% 0, 100% 0, 68% 44%, 100% 44%, 22% 100%, 45% 55%, 8% 55%);
|
||||||
|
transform: translateY(70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
color: var(--teal-dark);
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.topbar,
|
||||||
|
.current-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.forecast-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.page-shell {
|
||||||
|
width: min(100% - 20px, 1120px);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-summary {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-art {
|
||||||
|
width: 86px;
|
||||||
|
height: 86px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-summary h2 {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics,
|
||||||
|
.forecast-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="Weather">
|
||||||
|
<rect width="96" height="96" rx="8" fill="#ffffff"/>
|
||||||
|
<circle cx="34" cy="34" r="16" fill="#f2a413"/>
|
||||||
|
<path d="M30 62h39a14 14 0 0 0 0-28 20 20 0 0 0-37-7 17 17 0 0 0-2 35z" fill="#3d7ea6"/>
|
||||||
|
<path d="M29 62h40a14 14 0 0 0 12-7H23a13 13 0 0 0 6 7z" fill="#00796b"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 372 B |
@@ -0,0 +1,126 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Weather Forecast</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="page-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<img class="brand-icon" src="{{ url_for('static', filename='weather-mark.svg') }}" alt="">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Open-Meteo forecast</p>
|
||||||
|
<h1>{{ forecast.location.display if forecast else default_location }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="search" method="get">
|
||||||
|
<label class="sr-only" for="location">Location</label>
|
||||||
|
<input
|
||||||
|
id="location"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
value="{{ query }}"
|
||||||
|
placeholder="City or ZIP"
|
||||||
|
autocomplete="postal-code"
|
||||||
|
>
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<section class="alert" role="alert">
|
||||||
|
<strong>Forecast error:</strong> {{ error }}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if forecast %}
|
||||||
|
<section class="current-panel condition-{{ forecast.current.icon }}" aria-label="Current weather">
|
||||||
|
<div class="current-summary">
|
||||||
|
<div class="weather-art {{ forecast.current.icon }}" aria-hidden="true">
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{{ forecast.current.time }}</p>
|
||||||
|
<h2>{{ forecast.current.temperature }}</h2>
|
||||||
|
<p class="condition-label">{{ forecast.current.label }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="metrics">
|
||||||
|
<div>
|
||||||
|
<dt>Feels like</dt>
|
||||||
|
<dd>{{ forecast.current.apparent_temperature }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Humidity</dt>
|
||||||
|
<dd>{{ forecast.current.humidity }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Wind</dt>
|
||||||
|
<dd>{{ forecast.current.wind }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Gusts</dt>
|
||||||
|
<dd>{{ forecast.current.wind_gusts }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Clouds</dt>
|
||||||
|
<dd>{{ forecast.current.cloud_cover }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Precip</dt>
|
||||||
|
<dd>{{ forecast.current.precipitation }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="forecast-grid" aria-label="Five day forecast">
|
||||||
|
{% for day in forecast.days %}
|
||||||
|
<article class="day-card">
|
||||||
|
<div class="day-header">
|
||||||
|
<div class="mini-art {{ day.icon }}" aria-hidden="true"><span></span></div>
|
||||||
|
<div>
|
||||||
|
<h2>{{ day.date }}</h2>
|
||||||
|
<p>{{ day.label }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="day-metrics">
|
||||||
|
<div>
|
||||||
|
<dt>High</dt>
|
||||||
|
<dd>{{ day.high }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Low</dt>
|
||||||
|
<dd>{{ day.low }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Rain chance</dt>
|
||||||
|
<dd>{{ day.precip_probability }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Rain total</dt>
|
||||||
|
<dd>{{ day.precip_sum }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Max wind</dt>
|
||||||
|
<dd>{{ day.wind }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>Generated in {{ forecast.generation_ms }} ms.</span>
|
||||||
|
<a href="https://open-meteo.com/" rel="noreferrer">Open-Meteo</a>
|
||||||
|
</footer>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user