Swap hand-drawn weather icons for Environment Canada's real icon set
The hand-drawn glyphs (draw_cloud/draw_sun/draw_raindrop/draw_snowflake/ draw_lightning_bolt) are replaced by 7 vendored bitmaps, one per shared weather category, sourced from weather.gc.ca's public icon set -- these are small, flat-shaded images that dither cleanly onto the panel's 6-color palette and read as recognizable weather icons in a way the hand-drawn attempt (a plain circle-with-ticks "sun") didn't. Used for every provider's rendering (Open-Meteo, NWS, EC), not just when EC is selected. Vendored (not fetched live at render time), matching this project's existing convention for the Noto Emoji fonts -- server/app/weather_icons/ SOURCE.md documents the source, attribution, and the licensing caveat (this is a personal, non-commercial project; the icon images' own copyright terms are less clearly permissive than the weather data's own End-use Licence, since they're served from the public website rather than ECCC's data servers). draw_weather_icon's signature changes from (draw, cx, cy, r, category, palette_rgb) to (img, cx, cy, r, category): pasting a bitmap needs the Image object, not just an ImageDraw handle, and palette_rgb is no longer needed since the shared _quantize step already maps whatever's on the composited canvas to the frame's actual palette -- no per-icon color resolution required anymore.
@@ -248,12 +248,16 @@ site, however far" matched a Miami, FL query to a site in Ontario,
|
|||||||
1824 km away, once the box widened enough to cover the whole country).
|
1824 km away, once the box widened enough to cover the whole country).
|
||||||
|
|
||||||
`app/weather_render.py` holds every weather-related drawing primitive:
|
`app/weather_render.py` holds every weather-related drawing primitive:
|
||||||
`draw_cloud`/`draw_weather_icon`/`draw_weather_row` (extracted out of
|
`draw_weather_icon`/`draw_weather_row` (extracted out of
|
||||||
`calendar_render.py`, which still imports `draw_weather_row` for its own
|
`calendar_render.py`, which still imports `draw_weather_row` for its own
|
||||||
embedded strip, unchanged) plus this widget's own `build_current`/
|
embedded strip, unchanged) plus this widget's own `build_current`/
|
||||||
`build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()`
|
`build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()`
|
||||||
-- the weather analogue of `calendar_render.py`'s own `_build_tasks`/
|
-- the weather analogue of `calendar_render.py`'s own `_build_tasks`/
|
||||||
`render_tasks_preview_png` relationship.
|
`render_tasks_preview_png` relationship. Icons are vendored bitmaps (`app/
|
||||||
|
weather_icons/`, one per shared category, sourced from Environment
|
||||||
|
Canada's own weather icon set -- see that directory's `SOURCE.md` for
|
||||||
|
attribution/licensing), not hand-drawn, and used for every provider's
|
||||||
|
rendering, not just when EC is selected as the provider.
|
||||||
|
|
||||||
## Known gaps (Phase 6, not yet done)
|
## Known gaps (Phase 6, not yet done)
|
||||||
|
|
||||||
|
|||||||
@@ -414,8 +414,7 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
|
|||||||
weather_entries = _weather_for_day(weather_cities, day)
|
weather_entries = _weather_for_day(weather_cities, day)
|
||||||
if weather_entries:
|
if weather_entries:
|
||||||
y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
|
y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
|
||||||
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units,
|
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units)
|
||||||
palette_rgb=palette_rgb)
|
|
||||||
|
|
||||||
day_events = _events_on_day(events, day, tz)
|
day_events = _events_on_day(events, day, tz)
|
||||||
row_h = body_font.size + 14
|
row_h = body_font.size + 14
|
||||||
@@ -637,8 +636,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
|
|||||||
weather_entries = _weather_for_day(weather_cities, day)
|
weather_entries = _weather_for_day(weather_cities, day)
|
||||||
if weather_entries:
|
if weather_entries:
|
||||||
y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
|
y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
|
||||||
icon_r=8, font=weather_font, units=weather_units, show_labels=False,
|
icon_r=8, font=weather_font, units=weather_units, show_labels=False)
|
||||||
palette_rgb=palette_rgb)
|
|
||||||
row_h = chip_font.size + 10
|
row_h = chip_font.size + 10
|
||||||
max_rows = max(0, (target_h - MARGIN - y) // row_h)
|
max_rows = max(0, (target_h - MARGIN - y) // row_h)
|
||||||
day_events = _events_on_day(events, day, tz)
|
day_events = _events_on_day(events, day, tz)
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ HEADERS = {"User-Agent": "espresso_frame-weather-widget (self-hosted photo frame
|
|||||||
# the same handful of categories Open-Meteo's _CODE_CATEGORIES maps WMO
|
# the same handful of categories Open-Meteo's _CODE_CATEGORIES maps WMO
|
||||||
# codes to. "wind_"-prefixed variants (e.g. wind_skc) are just the same
|
# codes to. "wind_"-prefixed variants (e.g. wind_skc) are just the same
|
||||||
# sky condition plus wind -- stripped before lookup, since this project's
|
# sky condition plus wind -- stripped before lookup, since this project's
|
||||||
# hand-drawn icons (app/weather_render.py) don't have a separate windy
|
# vendored icon set (app/weather_render.py, app/weather_icons/) doesn't
|
||||||
# glyph.
|
# have a separate windy glyph.
|
||||||
_ICON_CODE_CATEGORIES = {
|
_ICON_CODE_CATEGORIES = {
|
||||||
"skc": "clear", "clear": "clear",
|
"skc": "clear", "clear": "clear",
|
||||||
"few": "partly_cloudy", "sct": "partly_cloudy",
|
"few": "partly_cloudy", "sct": "partly_cloudy",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Weather icons
|
||||||
|
|
||||||
|
Source: Environment and Climate Change Canada's public weather icon set,
|
||||||
|
served from `https://weather.gc.ca/weathericons/`.
|
||||||
|
|
||||||
|
© His Majesty the King in Right of Canada, as represented by the Minister
|
||||||
|
of Environment and Climate Change Canada. Vendored (not fetched live at
|
||||||
|
render time) under Canada.ca's non-commercial-reproduction terms
|
||||||
|
(https://www.canada.ca/en/transparency/terms.html) -- this is a personal,
|
||||||
|
non-commercial project. If this project's use ever becomes commercial,
|
||||||
|
these specific image files need their own clearance (the ECCC Data
|
||||||
|
Servers End-use Licence that covers the weather *data* app/weather/ec.py
|
||||||
|
consumes is more permissive, but that licence is scoped to ECCC's data
|
||||||
|
servers -- these icons are served from the public website, not that
|
||||||
|
domain, so it doesn't clearly cover them).
|
||||||
|
|
||||||
|
One icon per shared weather category (see app/weather/__init__.py's
|
||||||
|
module docstring for the category set), picked as a representative
|
||||||
|
"day" variant from EC's full icon set
|
||||||
|
(https://weather.gc.ca/mainmenu/icon_e.html has the legend):
|
||||||
|
|
||||||
|
| file | EC icon code | original URL |
|
||||||
|
|---------------------|:------------:|--------------------------------------------------|
|
||||||
|
| clear.gif | 00 | https://weather.gc.ca/weathericons/00.gif |
|
||||||
|
| partly_cloudy.gif | 02 | https://weather.gc.ca/weathericons/02.gif |
|
||||||
|
| cloudy.gif | 10 | https://weather.gc.ca/weathericons/10.gif |
|
||||||
|
| fog.gif | 24 | https://weather.gc.ca/weathericons/24.gif |
|
||||||
|
| rain.gif | 12 | https://weather.gc.ca/weathericons/12.gif |
|
||||||
|
| snow.gif | 17 | https://weather.gc.ca/weathericons/17.gif |
|
||||||
|
| thunderstorm.gif | 19 | https://weather.gc.ca/weathericons/19.gif |
|
||||||
|
|
||||||
|
Used for every weather provider's rendering (Open-Meteo, NWS, and EC
|
||||||
|
itself), not just when EC is the selected provider -- see
|
||||||
|
app/weather_render.py's draw_weather_icon.
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
@@ -1,28 +1,36 @@
|
|||||||
"""Weather icon-drawing primitives (draw_cloud/draw_weather_icon/
|
"""Weather icon-drawing primitives (draw_weather_icon/draw_weather_row --
|
||||||
draw_weather_row -- extracted out of calendar_render.py, which still
|
extracted out of calendar_render.py, which still imports draw_weather_row
|
||||||
imports draw_weather_row for its own embedded weather strip, unchanged)
|
for its own embedded weather strip, unchanged) plus the standalone
|
||||||
plus the standalone weather widget's four per-mode renderers
|
weather widget's four per-mode renderers (build_current/build_hourly/
|
||||||
(build_current/build_hourly/build_daily/build_multi_city, dispatched by
|
build_daily/build_multi_city, dispatched by build()) and its preview-PNG
|
||||||
build()) and its preview-PNG wrapper -- the weather analogue of
|
wrapper -- the weather analogue of calendar_render.py's own
|
||||||
calendar_render.py's own _build_tasks/render_tasks_preview_png
|
_build_tasks/render_tasks_preview_png relationship.
|
||||||
relationship.
|
|
||||||
|
|
||||||
Every build_* function takes already-normalized data (see app/weather/'s
|
Every build_* function takes already-normalized data (see app/weather/'s
|
||||||
provider modules -- a `category` key from the shared clear/partly_cloudy/
|
provider modules -- a `category` key from the shared clear/partly_cloudy/
|
||||||
cloudy/fog/rain/snow/thunderstorm set, never a raw provider code) and
|
cloudy/fog/rain/snow/thunderstorm set, never a raw provider code) and
|
||||||
returns an RGB Image exactly target_w x target_h, same contract every
|
returns an RGB Image exactly target_w x target_h, same contract every
|
||||||
other widget renderer in this project follows.
|
other widget renderer in this project follows.
|
||||||
|
|
||||||
|
Icons are Environment Canada's own weather icon set (vendored at
|
||||||
|
app/weather_icons/, one per shared category -- see that directory's
|
||||||
|
SOURCE.md for attribution/licensing), not hand-drawn: these are small,
|
||||||
|
flat-shaded bitmaps that dither cleanly onto the panel's 6-color
|
||||||
|
palette, and read as recognizable weather icons at a glance in a way an
|
||||||
|
earlier hand-drawn attempt (a plain circle-with-ticks "sun") didn't.
|
||||||
|
Used for every provider's rendering, not just when EC is selected.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import math
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
from .image_pipeline import DEFAULT_PALETTE_RGB, _apply_manage_overlay, _quantize, draw_text, logical_render_size
|
from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size
|
||||||
|
|
||||||
MARGIN = 20
|
MARGIN = 20
|
||||||
BG = (255, 255, 255)
|
BG = (255, 255, 255)
|
||||||
@@ -30,122 +38,37 @@ FG = (0, 0, 0)
|
|||||||
MUTED = (110, 110, 110)
|
MUTED = (110, 110, 110)
|
||||||
RULE = (0, 0, 0)
|
RULE = (0, 0, 0)
|
||||||
|
|
||||||
|
_ICON_DIR = Path(__file__).parent / "weather_icons"
|
||||||
def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
|
_ICON_FALLBACK = "cloudy" # unrecognized category (shouldn't happen with a valid provider) -- see draw_weather_icon
|
||||||
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
|
|
||||||
index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white,
|
|
||||||
already this module's BG/FG) -- same resolution idiom as
|
|
||||||
calendar_render.py's _event_colors, so a custom palette override
|
|
||||||
(Frame.palette_rgb) still gets its own actual yellow/blue, not a
|
|
||||||
hardcoded RGB triple."""
|
|
||||||
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
|
|
||||||
|
|
||||||
|
|
||||||
def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None:
|
@lru_cache(maxsize=None)
|
||||||
"""A simple puffy-cloud silhouette (three overlapping lobes + a base)
|
def _icon_asset(category: str) -> Image.Image:
|
||||||
with a clean outline -- drawn as one outline-color pass slightly
|
"""This category's vendored icon, loaded once (a handful of small
|
||||||
larger than the shapes, then the same shapes again in `fill` on top.
|
static files, never fetched over the network at render time) and
|
||||||
Overlapping ellipses each drawn with their own `outline=` would leave
|
cached at its native resolution -- draw_weather_icon resizes a copy
|
||||||
visible seams where they cross; this double-draw trick sidesteps that
|
per call, since the requested size varies with icon_r."""
|
||||||
entirely regardless of how the lobes overlap."""
|
path = _ICON_DIR / f"{category}.gif"
|
||||||
stroke = 2
|
if not path.exists():
|
||||||
lobes = [
|
path = _ICON_DIR / f"{_ICON_FALLBACK}.gif" # unrecognized category -- shouldn't happen with a valid provider
|
||||||
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
|
return Image.open(path).convert("RGBA")
|
||||||
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
|
|
||||||
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
|
|
||||||
]
|
|
||||||
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
|
|
||||||
for x0, y0, x1, y1 in lobes:
|
|
||||||
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=outline)
|
|
||||||
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=outline)
|
|
||||||
for x0, y0, x1, y1 in lobes:
|
|
||||||
draw.ellipse([x0, y0, x1, y1], fill=fill)
|
|
||||||
draw.rectangle(base, fill=fill)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_sun(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, color) -> None:
|
def draw_weather_icon(img: Image.Image, cx: float, cy: float, r: float, category: str) -> None:
|
||||||
"""A filled disc + 8 thick radiating rays -- the standard weather-app
|
"""Pastes this category's icon (see module docstring), scaled to
|
||||||
"sun" glyph, in the panel's actual yellow ink rather than a bare
|
roughly 2r wide, centered on (cx, cy). Alpha-composited via the
|
||||||
outline (a plain circle-with-4-ticks at small sizes read as a
|
icon's own transparency (confirmed present in the source GIFs), so
|
||||||
crosshair/target, not a sun)."""
|
only the glyph itself shows against whatever's already on `img`."""
|
||||||
draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color)
|
icon = _icon_asset(category)
|
||||||
for i in range(8):
|
size = max(1, round(r * 2))
|
||||||
angle = i * (math.pi / 4)
|
aspect_h = max(1, round(size * icon.height / icon.width))
|
||||||
dx, dy = math.cos(angle), math.sin(angle)
|
resized = icon.resize((size, aspect_h), Image.LANCZOS)
|
||||||
draw.line([(cx + dx * r * 0.68, cy + dy * r * 0.68), (cx + dx * r * 1.05, cy + dy * r * 1.05)],
|
img.paste(resized, (round(cx - size / 2), round(cy - aspect_h / 2)), resized)
|
||||||
fill=color, width=max(2, round(r * 0.14)))
|
|
||||||
|
|
||||||
|
|
||||||
def draw_raindrop(draw: ImageDraw.ImageDraw, x: float, y: float, size: float, color) -> None:
|
|
||||||
"""A rounded teardrop (point up, bulb down) -- the standard rain
|
|
||||||
glyph, not a bare diagonal tick."""
|
|
||||||
draw.polygon([(x, y), (x - size * 0.38, y + size * 0.55), (x + size * 0.38, y + size * 0.55)], fill=color)
|
|
||||||
draw.ellipse([x - size * 0.4, y + size * 0.25, x + size * 0.4, y + size * 1.05], fill=color)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_snowflake(draw: ImageDraw.ImageDraw, x: float, y: float, r: float, color) -> None:
|
|
||||||
"""A 6-pointed asterisk -- the standard snowflake glyph."""
|
|
||||||
for i in range(3):
|
|
||||||
angle = i * (math.pi / 3)
|
|
||||||
dx, dy = math.cos(angle) * r, math.sin(angle) * r
|
|
||||||
draw.line([(x - dx, y - dy), (x + dx, y + dy)], fill=color, width=max(2, round(r * 0.3)))
|
|
||||||
|
|
||||||
|
|
||||||
def draw_lightning_bolt(draw: ImageDraw.ImageDraw, cx: float, cy: float, size: float, color) -> None:
|
|
||||||
"""A zigzag bolt polygon -- the standard lightning glyph, not a bare
|
|
||||||
3-segment line."""
|
|
||||||
points = [
|
|
||||||
(cx + size * 0.15, cy - size * 0.7),
|
|
||||||
(cx - size * 0.35, cy + size * 0.05),
|
|
||||||
(cx - size * 0.05, cy + size * 0.05),
|
|
||||||
(cx - size * 0.2, cy + size * 0.7),
|
|
||||||
(cx + size * 0.4, cy - size * 0.1),
|
|
||||||
(cx + size * 0.05, cy - size * 0.1),
|
|
||||||
]
|
|
||||||
draw.polygon(points, fill=color)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str,
|
|
||||||
palette_rgb: list | None = None) -> None:
|
|
||||||
"""A small glyph for one weather category -- no custom font/icon
|
|
||||||
asset (hand-primitives only, same approach calendar_render.py uses
|
|
||||||
elsewhere for e.g. month view's density dots), but drawn in the
|
|
||||||
panel's own ink colors (yellow sun/bolt, blue rain/snow) rather than
|
|
||||||
flat black -- a plain monochrome outline at small sizes read as
|
|
||||||
abstract shapes (a "sun" that looked like a crosshair), not
|
|
||||||
recognizable weather icons."""
|
|
||||||
yellow = _ink(palette_rgb, 2)
|
|
||||||
blue = _ink(palette_rgb, 4)
|
|
||||||
|
|
||||||
if category == "clear":
|
|
||||||
draw_sun(draw, cx, cy, r, yellow)
|
|
||||||
return
|
|
||||||
|
|
||||||
if category == "partly_cloudy":
|
|
||||||
draw_sun(draw, cx - r * 0.45, cy - r * 0.45, r * 0.75, yellow)
|
|
||||||
draw_cloud(draw, cx + r * 0.1, cy + r * 0.2, r * 0.9)
|
|
||||||
return
|
|
||||||
|
|
||||||
cloud_cy = cy if category in ("cloudy", "fog") else cy - r * 0.25
|
|
||||||
draw_cloud(draw, cx, cloud_cy, r)
|
|
||||||
|
|
||||||
if category == "fog":
|
|
||||||
for i in range(3):
|
|
||||||
y = cy + r * 0.55 + i * (r * 0.4)
|
|
||||||
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
|
|
||||||
elif category == "rain":
|
|
||||||
for dx in (-0.55, 0, 0.55):
|
|
||||||
draw_raindrop(draw, cx + dx * r, cloud_cy + r * 0.55, r * 0.55, blue)
|
|
||||||
elif category == "snow":
|
|
||||||
for dx in (-0.55, 0, 0.55):
|
|
||||||
draw_snowflake(draw, cx + dx * r, cloud_cy + r * 0.85, r * 0.3, blue)
|
|
||||||
elif category == "thunderstorm":
|
|
||||||
draw_lightning_bolt(draw, cx, cloud_cy + r * 0.7, r * 0.7, yellow)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
|
def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
|
||||||
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
|
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
|
||||||
show_labels: bool = True, palette_rgb: list | None = None) -> int:
|
show_labels: bool = True) -> int:
|
||||||
"""Draws one or more cities' weather side by side starting at
|
"""Draws one or more cities' weather side by side starting at
|
||||||
(x0, y0), stopping once another entry wouldn't fit within max_w
|
(x0, y0), stopping once another entry wouldn't fit within max_w
|
||||||
(narrow views like week columns just end up showing fewer cities --
|
(narrow views like week columns just end up showing fewer cities --
|
||||||
@@ -165,7 +88,7 @@ def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: i
|
|||||||
if drew_any and x + entry_w > x0 + max_w:
|
if drew_any and x + entry_w > x0 + max_w:
|
||||||
break
|
break
|
||||||
cx, cy = x + icon_r, y0 + icon_r
|
cx, cy = x + icon_r, y0 + icon_r
|
||||||
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
draw_weather_icon(img, cx, cy, icon_r, entry["category"])
|
||||||
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
|
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
|
||||||
x += entry_w
|
x += entry_w
|
||||||
drew_any = True
|
drew_any = True
|
||||||
@@ -217,7 +140,7 @@ def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb:
|
|||||||
|
|
||||||
icon_r = max(20, min(target_w, target_h) // 4)
|
icon_r = max(20, min(target_w, target_h) // 4)
|
||||||
cx, cy = target_w // 2, target_h // 2 - icon_r // 2
|
cx, cy = target_w // 2, target_h // 2 - icon_r // 2
|
||||||
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
draw_weather_icon(img, cx, cy, icon_r, entry["category"])
|
||||||
|
|
||||||
unit_suffix = "F" if units == "fahrenheit" else "C"
|
unit_suffix = "F" if units == "fahrenheit" else "C"
|
||||||
temp_size = max(24, min(target_w, target_h) // 3)
|
temp_size = max(24, min(target_w, target_h) // 3)
|
||||||
@@ -281,7 +204,7 @@ def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb:
|
|||||||
tbbox = draw.textbbox((0, 0), time_label, font=label_font)
|
tbbox = draw.textbbox((0, 0), time_label, font=label_font)
|
||||||
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED)
|
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED)
|
||||||
cy = y + label_size + 10 + icon_r
|
cy = y + label_size + 10 + icon_r
|
||||||
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
|
draw_weather_icon(img, cx, cy, icon_r, entry["category"])
|
||||||
temp_label = temp_labels[i]
|
temp_label = temp_labels[i]
|
||||||
tempbbox = draw.textbbox((0, 0), temp_label, font=label_font)
|
tempbbox = draw.textbbox((0, 0), temp_label, font=label_font)
|
||||||
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, label_font)
|
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, label_font)
|
||||||
@@ -326,7 +249,7 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
|
|||||||
lbbox = draw.textbbox((0, 0), label, font=label_font)
|
lbbox = draw.textbbox((0, 0), label, font=label_font)
|
||||||
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font, MUTED)
|
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font, MUTED)
|
||||||
cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
|
cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
|
||||||
draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb)
|
draw_weather_icon(img, cx, cy, icon_r, d["category"])
|
||||||
tbbox = draw.textbbox((0, 0), temps, font=label_font)
|
tbbox = draw.textbbox((0, 0), temps, font=label_font)
|
||||||
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font)
|
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font)
|
||||||
return img
|
return img
|
||||||
@@ -364,8 +287,7 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r
|
|||||||
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r)
|
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r)
|
||||||
font = ImageFont.load_default(size=font_size)
|
font = ImageFont.load_default(size=font_size)
|
||||||
y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2)
|
y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2)
|
||||||
draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units,
|
draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units, show_labels=True)
|
||||||
show_labels=True, palette_rgb=palette_rgb)
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,15 @@ Mirrors calendar_render.py's own testing posture (this module has no
|
|||||||
dedicated test file there either, since it's exercised indirectly via
|
dedicated test file there either, since it's exercised indirectly via
|
||||||
test_widgets_calendar.py) but covers the non-obvious behavior worth
|
test_widgets_calendar.py) but covers the non-obvious behavior worth
|
||||||
pinning down directly: build_multi_city's label shortening, and that
|
pinning down directly: build_multi_city's label shortening, and that
|
||||||
weather icons actually use the panel's ink colors (not flat black --
|
every shared category actually has a distinct vendored icon (a missing
|
||||||
caught via browser verification that a monochrome sun read as an
|
or misnamed asset file would otherwise only surface as a silent
|
||||||
unrecognizable crosshair glyph, not a sun)."""
|
fallback to the "cloudy" glyph, not an error)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image
|
||||||
|
|
||||||
from app import weather_render
|
from app import weather_render
|
||||||
from app.image_pipeline import DEFAULT_PALETTE_RGB
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch):
|
def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch):
|
||||||
@@ -26,10 +25,9 @@ def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch)
|
|||||||
seen_labels = []
|
seen_labels = []
|
||||||
real_draw_weather_row = weather_render.draw_weather_row
|
real_draw_weather_row = weather_render.draw_weather_row
|
||||||
|
|
||||||
def spy(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels=True, palette_rgb=None):
|
def spy(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels=True):
|
||||||
seen_labels.extend(e["label"] for e in entries)
|
seen_labels.extend(e["label"] for e in entries)
|
||||||
return real_draw_weather_row(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels,
|
return real_draw_weather_row(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels)
|
||||||
palette_rgb)
|
|
||||||
|
|
||||||
monkeypatch.setattr(weather_render, "draw_weather_row", spy)
|
monkeypatch.setattr(weather_render, "draw_weather_row", spy)
|
||||||
|
|
||||||
@@ -47,40 +45,40 @@ def test_build_multi_city_empty_list_returns_blank_canvas():
|
|||||||
assert img.size == (400, 150)
|
assert img.size == (400, 150)
|
||||||
|
|
||||||
|
|
||||||
def _colors_present(img: Image.Image) -> set[tuple[int, int, int]]:
|
def test_icon_asset_exists_and_loads_for_every_shared_category():
|
||||||
return {c for _, c in img.getcolors(maxcolors=100_000)}
|
"""The shared category set (see app/weather/__init__.py's module
|
||||||
|
docstring) must each resolve to a real vendored file, not silently
|
||||||
|
fall back to "cloudy" -- a typo'd filename would otherwise only show
|
||||||
|
up as every OTHER category quietly looking like a plain cloud."""
|
||||||
|
for category in ("clear", "partly_cloudy", "cloudy", "fog", "rain", "snow", "thunderstorm"):
|
||||||
|
path = weather_render._ICON_DIR / f"{category}.gif"
|
||||||
|
assert path.exists(), f"missing vendored icon for {category!r}"
|
||||||
|
icon = weather_render._icon_asset(category)
|
||||||
|
assert icon.mode == "RGBA"
|
||||||
|
assert icon.width > 0 and icon.height > 0
|
||||||
|
|
||||||
|
|
||||||
def test_draw_weather_icon_clear_uses_panel_yellow_not_flat_black():
|
def test_icon_asset_is_cached():
|
||||||
|
assert weather_render._icon_asset("clear") is weather_render._icon_asset("clear")
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_weather_icon_pastes_a_visibly_different_glyph_per_category():
|
||||||
|
"""Not a pixel-exact check (the actual art is a vendored asset, not
|
||||||
|
something this test should hardcode) -- just confirms distinct
|
||||||
|
categories actually produce visibly different canvases, i.e. the
|
||||||
|
right file is being loaded per category rather than one glyph
|
||||||
|
silently reused for all of them."""
|
||||||
|
rendered = {}
|
||||||
|
for category in ("clear", "rain", "snow", "thunderstorm"):
|
||||||
|
img = Image.new("RGB", (100, 100), (255, 255, 255))
|
||||||
|
weather_render.draw_weather_icon(img, 50, 50, 30, category)
|
||||||
|
rendered[category] = img.tobytes()
|
||||||
|
assert len(set(rendered.values())) == len(rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_weather_icon_unrecognized_category_falls_back_to_cloudy():
|
||||||
img = Image.new("RGB", (100, 100), (255, 255, 255))
|
img = Image.new("RGB", (100, 100), (255, 255, 255))
|
||||||
draw = ImageDraw.Draw(img)
|
weather_render.draw_weather_icon(img, 50, 50, 30, "not_a_real_category")
|
||||||
weather_render.draw_weather_icon(draw, 50, 50, 30, "clear")
|
fallback = Image.new("RGB", (100, 100), (255, 255, 255))
|
||||||
colors = _colors_present(img)
|
weather_render.draw_weather_icon(fallback, 50, 50, 30, "cloudy")
|
||||||
assert tuple(DEFAULT_PALETTE_RGB[2]) in colors # yellow
|
assert img.tobytes() == fallback.tobytes()
|
||||||
assert (0, 0, 0) not in colors # no flat-black sun
|
|
||||||
|
|
||||||
|
|
||||||
def test_draw_weather_icon_rain_uses_panel_blue():
|
|
||||||
img = Image.new("RGB", (100, 100), (255, 255, 255))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
weather_render.draw_weather_icon(draw, 50, 50, 30, "rain")
|
|
||||||
assert tuple(DEFAULT_PALETTE_RGB[4]) in _colors_present(img) # blue raindrops
|
|
||||||
|
|
||||||
|
|
||||||
def test_draw_weather_icon_thunderstorm_uses_panel_yellow():
|
|
||||||
img = Image.new("RGB", (100, 100), (255, 255, 255))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
weather_render.draw_weather_icon(draw, 50, 50, 30, "thunderstorm")
|
|
||||||
assert tuple(DEFAULT_PALETTE_RGB[2]) in _colors_present(img) # yellow bolt
|
|
||||||
|
|
||||||
|
|
||||||
def test_draw_weather_icon_respects_a_custom_frame_palette():
|
|
||||||
"""A frame with an Advanced-configuration palette override (see
|
|
||||||
Frame.palette_rgb) should still get ITS actual yellow, not the
|
|
||||||
hardcoded default -- same resolution rule as calendar_render.py's
|
|
||||||
_event_colors."""
|
|
||||||
custom_palette = [(0, 0, 0), (255, 255, 255), (10, 20, 30), (0, 0, 0), (0, 0, 0), (0, 0, 0)]
|
|
||||||
img = Image.new("RGB", (100, 100), (255, 255, 255))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
weather_render.draw_weather_icon(draw, 50, 50, 30, "clear", palette_rgb=custom_palette)
|
|
||||||
assert (10, 20, 30) in _colors_present(img)
|
|
||||||
|
|||||||