-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcards.py
More file actions
308 lines (268 loc) · 13.7 KB
/
Copy pathcards.py
File metadata and controls
308 lines (268 loc) · 13.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
"""Renders the morning briefing as a newspaper-style dashboard image.
Sized 1200x630 so one asset serves both jobs: the MMS card sent into the SMS
thread, and the og:image for the link preview. Drawn with Pillow rather than a
headless browser — Chromium will not fit a 512MB Basic dyno.
Everything here is driven by the structured snapshots (weather.weather_snapshot,
traffic.traffic_snapshot, datafeeds.price_snapshot), not by parsing prose. The
WMO weather_code no longer picks illustrated art — flat paper and hairline
rules read as "newspaper", not icons — but still gates the one-word condition
label; the traffic live/free-flow ratio drives the meter; price deltas colour
the market rows. Colour is rationed to temperature and the commute marker, the
same two spots page.py spends it, so the MMS preview and the page read as one
publication.
Every section degrades independently: a missing snapshot leaves its panel out
rather than failing the render, because a briefing that arrives without a card
is fine and a briefing that fails to send is not.
"""
from __future__ import annotations
import io
import os
from datetime import datetime
from PIL import Image, ImageDraw, ImageFont
W, H = 1200, 630
PAD = 60
# palette — flat paper white, ink-black type, hairline rules. Colour is spent
# only on temperature (warm/cool), the commute marker, and market deltas.
# Pillow flattens to RGB at save time without alpha compositing, so every
# colour here is a solid RGB triplet — no translucent overlays to fake.
PAPER = (247, 245, 239)
INK = (22, 21, 16)
MUTED = (92, 88, 76)
RULE = (214, 210, 198)
UP, DOWN = (31, 110, 58), (163, 39, 31)
WARM = (168, 70, 26)
COOL = (31, 90, 140)
AMBER = (138, 90, 16)
_FONT_DIRS = (
"/usr/share/fonts/truetype/dejavu", # heroku slug
"/System/Library/Fonts/Supplemental", # macOS
"/Library/Fonts",
"/usr/share/fonts/truetype",
)
# Serif first for the newspaper feel; DejaVuSans/Helvetica/Arial are the
# fallback chain when a serif face isn't installed, so the render never fails
# for want of a font — it just loses the editorial look.
_FONT_FILES = {
False: ("DejaVuSerif.ttf", "Georgia.ttf", "Times New Roman.ttf",
"DejaVuSans.ttf", "Helvetica.ttc", "Arial.ttf"),
True: ("DejaVuSerif-Bold.ttf", "Georgia Bold.ttf", "Times New Roman Bold.ttf",
"DejaVuSans-Bold.ttf", "Helvetica-Bold.ttf", "Arial Bold.ttf"),
}
_MONO_DIRS = (
"/usr/share/fonts/truetype/dejavu",
"/System/Library/Fonts/Supplemental",
"/System/Library/Fonts", # macOS: Menlo lives here
"/Library/Fonts",
"/usr/share/fonts/truetype",
)
# Menlo.ttc is the last mono entry in BOTH rows on purpose: macOS ships no
# "Menlo-Bold.ttc", so a bold lookup that only listed that name fell through to
# Pillow's builtin bitmap face — which does not scale, and rendered the 118pt
# hero temperature at about 8px. Production was never affected (the slug has
# DejaVu), but the card is reviewed by rendering it locally, so a local render
# that does not look like the real one is worse than useless.
_MONO_FILES = {
False: ("DejaVuSansMono.ttf", "Menlo.ttc", "Consolas.ttf", "DejaVuSans.ttf"),
True: ("DejaVuSansMono-Bold.ttf", "Consolas Bold.ttf", "DejaVuSans-Bold.ttf",
"Menlo.ttc"),
}
_font_cache: dict[tuple[int, bool], ImageFont.FreeTypeFont] = {}
_mono_cache: dict[tuple[int, bool], ImageFont.FreeTypeFont] = {}
def _resolve(dirs, files_by_bold, cache, size: int, bold: bool) -> ImageFont.FreeTypeFont:
key = (size, bold)
if key in cache:
return cache[key]
for d in dirs:
for name in files_by_bold[bold]:
path = os.path.join(d, name)
if os.path.exists(path):
try:
f = ImageFont.truetype(path, size)
cache[key] = f
return f
except Exception:
continue
f = ImageFont.load_default()
cache[key] = f
return f
def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
"""Serif face for headline-style text. Resolves across slug and local dev;
falls back to Pillow's builtin so a missing font never takes the render down."""
return _resolve(_FONT_DIRS, _FONT_FILES, _font_cache, size, bold)
def _mono(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
"""Monospace face for numeric data — temps, prices, times — the one
"digital" texture against the serif editorial type."""
return _resolve(_MONO_DIRS, _MONO_FILES, _mono_cache, size, bold)
def _tw(draw: ImageDraw.ImageDraw, text: str, font) -> int:
return int(draw.textlength(text, font=font))
def _background() -> Image.Image:
"""Flat paper white — no gradient, no glow. The masthead rule and section
hairlines carry the structure instead."""
return Image.new("RGB", (W, H), PAPER)
def _hrule(d: ImageDraw.ImageDraw, x0: int, x1: int, y: int, fill=RULE, width: int = 1) -> None:
d.line([(x0, y), (x1, y)], fill=fill, width=width)
def _meter(img: Image.Image, x: int, y: int, w: int, ratio: float) -> tuple[int, int, int]:
"""Congestion gauge: a plain grey hairline with a single coloured marker.
A proportional fill bar was the first attempt and it misread badly — a
free-flowing commute (ratio ~1.02) rendered as a nearly empty track, which
looks like a stalled progress bar rather than good news. A marker on a
graded scale says "you are here, and here is good" — and it's the only
colour this row spends.
"""
span = max(0.0, min(1.0, (ratio - 1.0) / 0.5)) # 1.0..1.5 -> 0..1
colour = UP if span < 0.34 else (WARM if span < 0.67 else DOWN)
d = ImageDraw.Draw(img)
d.line([(x, y), (x + w, y)], fill=RULE, width=2)
nx = x + int(span * (w - 12)) + 6
d.ellipse([nx - 7, y - 7, nx + 7, y + 7], fill=colour)
return colour
def _sparkline(d: ImageDraw.ImageDraw, x: int, y: int, w: int, h: int,
series: list[float], colour) -> None:
pts = [p for p in (series or []) if isinstance(p, (int, float))]
if len(pts) < 2:
return
lo, hi = min(pts), max(pts)
rng = (hi - lo) or 1.0
step = w / (len(pts) - 1)
coords = [(x + i * step, y + h - ((p - lo) / rng) * h) for i, p in enumerate(pts)]
d.line(coords, fill=colour, width=2, joint="curve")
d.ellipse([coords[-1][0] - 3, coords[-1][1] - 3, coords[-1][0] + 3, coords[-1][1] + 3], fill=colour)
# Markets columns the card has room for. Four fits the width but the sparklines
# start overdrawing the price text ("$214.72" with a line through it), so three
# is the layout's real limit. This is a rendering cap, not a data cap: the
# payload may carry more (see home.MAX_PRICES) and the page shows all of them,
# because a vertical list has room where a fixed-width row does not.
MAX_PRICES = 3
# Opening rows on the card. The page shows up to five; the card has one band of
# left column under the weather hero, and three is what fits without crowding
# the headlines rule below it.
CARD_OPENING_ROWS = 3
def render_dashboard(*, city: str, weather: dict | None, traffic: dict | None,
prices: list[dict] | None, headlines: list[str] | None,
opening: list[dict] | None = None,
when: datetime | None = None) -> bytes:
"""The briefing as a 1200x630 PNG. Returns encoded bytes.
Flat paper, ink type, hairline rules — no gradients, no glass panels, no
illustrated weather art. Colour appears in exactly three places: the
temperature (hot/cold), the commute marker, and market deltas — the same
accents page.py spends, so the MMS card and the page read as one thing.
"""
img = _background()
d = ImageDraw.Draw(img)
now = when or datetime.now()
# --- masthead -----------------------------------------------------------
d.text((PAD, PAD - 24), (city or "Today").upper(), font=_font(30, True), fill=INK)
date_txt = now.strftime("%A, %B %-d").upper()
df = _mono(16)
d.text((W - PAD - _tw(d, date_txt, df), PAD - 6), date_txt, font=df, fill=MUTED)
_hrule(d, PAD, W - PAD, PAD + 22, fill=INK, width=3)
_hrule(d, PAD, W - PAD, PAD + 28, fill=INK, width=1)
content_top = PAD + 70
right_x = PAD + 620
right_w = W - PAD - right_x
# --- weather hero (left column) -----------------------------------------
# Primary city only. A user's secondary weather_locations render on the
# page (page.py) but never here — the chips already run at their cap of 3
# and the gap between them (~y354) and the opening band (~y374, itself
# only 26px above the news rule at H-90) leaves no room for a second
# location on a fixed 1200x630 image. See home._fetch_weather_extra.
if weather:
temp = weather.get("temp_now")
if temp is not None:
temp_colour = WARM if temp >= 80 else (COOL if temp <= 40 else INK)
fh = _mono(118, True)
d.text((PAD - 4, content_top), f"{temp:.0f}°", font=fh, fill=temp_colour)
desc = (weather.get("description") or "").capitalize()
d.text((PAD, content_top + 138), desc, font=_font(28), fill=MUTED)
chips = []
hi, lo = weather.get("high"), weather.get("low")
if hi is not None and lo is not None:
chips.append((f"H {hi:.0f}° L {lo:.0f}°", MUTED))
if weather.get("rain_pct"):
chips.append((f"{weather['rain_pct']}% RAIN", COOL))
if weather.get("wind") is not None:
chips.append((f"{weather['wind']:.0f} MPH WIND", MUTED))
if weather.get("feels_like") is not None and temp is not None \
and abs(weather["feels_like"] - temp) >= 3:
chips.append((f"FEELS {weather['feels_like']:.0f}°", WARM))
cx, cy = PAD, content_top + 190
cf = _mono(16, True)
for label, colour in chips[:3]:
cw = _tw(d, label, cf) + 28
d.rounded_rectangle([cx, cy, cx + cw, cy + 34], radius=3, outline=colour, width=1)
d.text((cx + 14, cy + 9), label, font=cf, fill=colour)
cx += cw + 10
# --- commute (right column, top) ----------------------------------------
commute_bottom = content_top
if traffic:
d.text((right_x, content_top), "COMMUTE", font=_font(18, True), fill=MUTED)
mins = f"{traffic.get('live_min', 0)} min"
mf = _mono(42, True)
d.text((right_x, content_top + 24), mins, font=mf, fill=INK)
delay = traffic.get("delay_min") or 0
span = max(0.0, min(1.0, ((traffic.get("ratio") or 1.0) - 1.0) / 0.5))
tier_colour = UP if span < 0.34 else (WARM if span < 0.67 else DOWN)
note = "clear" if span < 0.34 else f"+{delay} min vs normal"
d.text((right_x + _tw(d, mins, mf) + 16, content_top + 40), note,
font=_mono(18), fill=tier_colour)
_meter(img, right_x, content_top + 88, right_w, traffic.get("ratio") or 1.0)
commute_bottom = content_top + 100
_hrule(d, right_x, right_x + right_w, commute_bottom + 20)
# --- markets (right column, below commute) ------------------------------
if prices:
m_top = commute_bottom + 44
d.text((right_x, m_top), "MARKETS", font=_font(18, True), fill=MUTED)
row_y = m_top + 32
lf, pf, xf = _font(19, True), _mono(20, True), _mono(15)
for p in prices[:MAX_PRICES]:
pct = p.get("pct_24h") or 0.0
colour = UP if pct >= 0 else DOWN
d.text((right_x, row_y + 4), (p.get("label", "") or "")[:14], font=lf, fill=INK)
price = p.get("price") or 0
ptxt = f"${price:,.0f}" if price >= 1000 else f"${price:,.2f}"
pct_txt = f"{pct:+.1f}%"
pw = max(_tw(d, ptxt, pf), _tw(d, pct_txt, xf))
d.text((right_x + right_w - pw, row_y - 2), ptxt, font=pf, fill=INK)
d.text((right_x + right_w - _tw(d, pct_txt, xf), row_y + 24), pct_txt, font=xf, fill=colour)
_sparkline(d, right_x + right_w - pw - 108, row_y + 6, 84, 24,
p.get("series") or [], colour)
row_y += 42
_hrule(d, right_x, right_x + right_w, row_y + 2)
# --- opening (left column, under the weather hero) ----------------------
# The hero's chips bottom out around y=354 and the headlines rule sits at
# H-90; this band is the gap between them, and it was empty. Left column
# only, so it stacks beside Markets rather than fighting it for width.
if opening:
o_top = content_top + 244
o_right = right_x - 40
_hrule(d, PAD, o_right, o_top)
d.text((PAD, o_top + 12), "OPENING", font=_font(15, True), fill=MUTED)
tf, wf = _font(20), _mono(14)
y = o_top + 38
for row in opening[:CARD_OPENING_ROWS]:
when_txt = (row.get("when") or "").upper()
ww = _tw(d, when_txt, wf) if when_txt else 0
title = (row.get("title") or "").strip()
avail = (o_right - PAD) - ww - 20
if _tw(d, title, tf) > avail:
while title and _tw(d, title + "…", tf) > avail:
title = title[:-1]
title = title.rstrip(" ,-") + "…"
d.text((PAD, y), title, font=tf, fill=INK)
if when_txt:
d.text((o_right - ww, y + 5), when_txt, font=wf, fill=MUTED)
y += 34
# --- headlines (footer band, full width) --------------------------------
if headlines:
band = H - 90
_hrule(d, PAD, W - PAD, band, fill=INK, width=1)
d.text((PAD, band + 10), "NEWS", font=_font(15, True), fill=MUTED)
f = _font(21)
y = band + 32
for h in headlines[:2]:
clipped = h if _tw(d, h, f) < W - 2 * PAD else h[:110] + "…"
d.text((PAD, y), clipped, font=f, fill=INK)
y += 26
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
return buf.getvalue()