331 lines
15 KiB
Python
331 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
渲染层 —— 将数据绘制成 400x300 纯黑白墨水屏仪表盘。
|
||
|
||
设计要点:
|
||
- 纯黑白(设备无真灰阶,灰阶只能抖动成网点,弃用);用构图 + 字号 + 留白建立层级。
|
||
- Fusion Pixel 点阵字体,按整数倍尺寸(12/24/48)渲染,fontmode='1' 关闭抗锯齿 → 像素锐利。
|
||
- 重点数据用「伪粗体」(偏移叠绘)加粗,仍对齐像素网格。
|
||
布局:上=大时钟+日期 / 天气;中=橘喵今日经营三列;下=近30天三指标趋势折线图。
|
||
"""
|
||
import math
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
import config
|
||
|
||
BLACK, WHITE = 0, 255
|
||
|
||
|
||
class DashboardRenderer:
|
||
def __init__(self, font_path=config.FONT_PATH, size=(config.WIDTH, config.HEIGHT)):
|
||
self.W, self.H = size
|
||
self.F12 = ImageFont.truetype(font_path, 12)
|
||
self.F24 = ImageFont.truetype(font_path, 24)
|
||
self.F48 = ImageFont.truetype(font_path, 48)
|
||
|
||
# ---------- 基础绘制工具 ----------
|
||
def _tw(self, s, ft):
|
||
return self.d.textlength(s, font=ft)
|
||
|
||
def _draw(self, x, y, s, ft, fill, bold):
|
||
# 伪粗体:点阵字体单一字重,按 1~bold px 偏移叠绘加粗笔画
|
||
self.d.text((x, y), s, font=ft, fill=fill)
|
||
for dx in range(1, bold + 1):
|
||
self.d.text((x + dx, y), s, font=ft, fill=fill)
|
||
|
||
def text(self, x, y, s, ft, fill=BLACK, bold=0):
|
||
self._draw(x, y, s, ft, fill, bold)
|
||
|
||
def rtext(self, xr, y, s, ft, fill=BLACK, bold=0):
|
||
self._draw(xr - self._tw(s, ft) - bold, y, s, ft, fill, bold)
|
||
|
||
def ctext(self, cx, y, s, ft, fill=BLACK, bold=0):
|
||
self._draw(cx - (self._tw(s, ft) + bold) / 2, y, s, ft, fill, bold)
|
||
|
||
def hdash(self, x0, x1, y, dash=4, gap=4, fill=BLACK):
|
||
x = x0
|
||
while x < x1:
|
||
self.d.line([x, y, min(x + dash, x1), y], fill=fill, width=1)
|
||
x += dash + gap
|
||
|
||
def vdash(self, x, y0, y1, dash=4, gap=4, fill=BLACK):
|
||
y = y0
|
||
while y < y1:
|
||
self.d.line([x, y, x, min(y + dash, y1)], fill=fill, width=1)
|
||
y += dash + gap
|
||
|
||
def tri(self, cx, cy, sz, up=True, fill=BLACK):
|
||
if up:
|
||
pts = [(cx, cy - sz / 2), (cx - sz / 2, cy + sz / 2), (cx + sz / 2, cy + sz / 2)]
|
||
else:
|
||
pts = [(cx, cy + sz / 2), (cx - sz / 2, cy - sz / 2), (cx + sz / 2, cy - sz / 2)]
|
||
self.d.polygon(pts, fill=fill)
|
||
|
||
# ---------- 组件 ----------
|
||
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
|
||
"""环比/同比一行:标签 + 涨跌三角 + 数值,整体居中。direction 为 None 时不画三角。"""
|
||
direction, val = pair
|
||
lw = self._tw(label, self.F12); gap = 5; nw = self._tw(val, self.F12)
|
||
trw, tgap = (8, 4) if direction else (0, 0)
|
||
x = cx - (lw + gap + trw + tgap + nw) / 2
|
||
self.text(x, y, label, self.F12, fill=fg); x += lw + gap
|
||
if direction:
|
||
# 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高
|
||
self.tri(x + trw / 2, y + 8.5, 7, up=(direction == "up"), fill=fg); x += trw + tgap
|
||
self.text(x, y, val, self.F12, fill=fg)
|
||
|
||
def _title_bar(self, y, title, upd):
|
||
"""标题栏:■ 标题(左) + 上次更新 HH:mm(右)。"""
|
||
sq, sq_y = 6, y + 4
|
||
self.d.rectangle([10, sq_y, 10 + sq, sq_y + sq], fill=BLACK)
|
||
self.text(10 + sq + 6, y, title, self.F12)
|
||
self.rtext(self.W - 10, y, f"上次更新 {upd}", self.F12)
|
||
|
||
def _loc_pin(self, x, y, w=8, fg=BLACK, bg=WHITE):
|
||
"""定位图标:实心水滴(圆头+尖尾) + 白色挖孔,1-bit 下轮廓清晰。
|
||
(x,y) 为头部圆左上角,w 为头径,总高约 w*1.5。"""
|
||
cx, cy = x + w / 2, y + w / 2 # 头部圆心
|
||
self.d.ellipse([x, y, x + w, y + w], fill=fg) # 头部实心圆
|
||
self.d.polygon([(x, cy), (x + w, cy), (cx, y + w + w * 0.5)], fill=fg) # 尾部尖
|
||
hr = w * 0.2
|
||
self.d.ellipse([cx - hr, cy - hr, cx + hr, cy + hr], fill=bg) # 头部白孔
|
||
|
||
@staticmethod
|
||
def _weather_category(icon):
|
||
"""和风天气 icon 代码 → 可枚举类别。无法识别时回退 'cloudy'。
|
||
参考 https://dev.qweather.com/docs/resource/icons/ :
|
||
100=晴日 150=晴夜 / 101~103,151~153=多云 / 104,154=阴 /
|
||
302~304=雷阵雨 / 300~399=雨 / 400~499=雪 / 500~599=雾霾沙尘。"""
|
||
try:
|
||
c = int(icon)
|
||
except (TypeError, ValueError):
|
||
return "cloudy"
|
||
if c == 100:
|
||
return "sunny"
|
||
if c == 150:
|
||
return "clear_night"
|
||
if c in (101, 102, 103, 151, 152, 153):
|
||
return "cloudy"
|
||
if c in (104, 154):
|
||
return "overcast"
|
||
if c in (302, 303, 304):
|
||
return "thunder"
|
||
if 300 <= c <= 399:
|
||
return "rain"
|
||
if 400 <= c <= 499:
|
||
return "snow"
|
||
if 500 <= c <= 599:
|
||
return "fog"
|
||
return "cloudy"
|
||
|
||
def _sun(self, cx, cy, r, s, fg, rays=True):
|
||
"""太阳:圆 + 8 道光芒(光芒可关)。"""
|
||
S = lambda v: v * s
|
||
self.d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=fg, width=2)
|
||
if rays:
|
||
for a in range(0, 360, 45):
|
||
rad = math.radians(a)
|
||
self.d.line([cx + math.cos(rad) * (r + S(3)), cy + math.sin(rad) * (r + S(3)),
|
||
cx + math.cos(rad) * (r + S(7)), cy + math.sin(rad) * (r + S(7))], fill=fg, width=2)
|
||
|
||
def _cloud(self, x, cy, s, fg, bg):
|
||
"""一朵云,cy 为云体中线高度;bg 填充内部以盖住其后的太阳光芒。"""
|
||
S = lambda v: v * s
|
||
self.d.ellipse([x + S(4), cy - S(2), x + S(20), cy + S(12)], fill=bg)
|
||
self.d.ellipse([x + S(14), cy - S(8), x + S(34), cy + S(12)], fill=bg)
|
||
self.d.rectangle([x + S(6), cy + S(4), x + S(34), cy + S(12)], fill=bg)
|
||
self.d.arc([x + S(2), cy - S(4), x + S(22), cy + S(14)], 90, 270, fill=fg, width=2)
|
||
self.d.arc([x + S(12), cy - S(10), x + S(36), cy + S(14)], 270, 90, fill=fg, width=2)
|
||
self.d.line([x + S(9), cy + S(13), x + S(29), cy + S(13)], fill=fg, width=2)
|
||
|
||
def _weather_icon(self, x, y, category, s=1.0, fg=BLACK, bg=WHITE):
|
||
"""按天气类别画图标,s 为缩放系数,原生约 36x38(左上角 x,y)。"""
|
||
S = lambda v: v * s
|
||
|
||
if category == "sunny":
|
||
self._sun(x + S(18), y + S(19), S(11), s, fg)
|
||
return
|
||
|
||
if category == "clear_night": # 月牙:实心圆挖去偏上右的背景圆
|
||
mx, my, r = x + S(18), y + S(19), S(13)
|
||
self.d.ellipse([mx - r, my - r, mx + r, my + r], fill=fg)
|
||
ox, oy, r2 = mx + S(6), my - S(5), S(12)
|
||
self.d.ellipse([ox - r2, oy - r2, ox + r2, oy + r2], fill=bg)
|
||
return
|
||
|
||
if category == "overcast": # 阴:单独一朵云,居中偏下
|
||
self._cloud(x, y + S(20), s, fg, bg)
|
||
return
|
||
|
||
if category == "fog": # 雾/霾/沙尘:横向雾线,长短交错
|
||
for i, dy in enumerate((9, 17, 25, 33)):
|
||
inset = S(5) if i % 2 else 0
|
||
self.d.line([x + inset, y + S(dy), x + S(34) - inset, y + S(dy)], fill=fg, width=2)
|
||
return
|
||
|
||
if category != "rain" and category != "snow" and category != "thunder": # 多云及未识别:太阳 + 云
|
||
self._sun(x + S(10), y + S(9), S(6), s, fg)
|
||
self._cloud(x, y + S(24), s, fg, bg)
|
||
return
|
||
|
||
# 以下三类:云在上,降水/天气符号在云下方
|
||
cb = y + S(15) + S(13) # 云底 y
|
||
self._cloud(x, y + S(15), s, fg, bg)
|
||
if category == "rain":
|
||
for dx in (9, 18, 27):
|
||
self.d.line([x + S(dx) + S(3), cb + S(3), x + S(dx) - S(1), cb + S(10)], fill=fg, width=2)
|
||
elif category == "snow":
|
||
for dx in (10, 19, 28):
|
||
fx, fy, rr = x + S(dx), cb + S(7), S(4)
|
||
for a in (0, 60, 120):
|
||
rad = math.radians(a)
|
||
self.d.line([fx - math.cos(rad) * rr, fy - math.sin(rad) * rr,
|
||
fx + math.cos(rad) * rr, fy + math.sin(rad) * rr], fill=fg, width=1)
|
||
elif category == "thunder":
|
||
bx = x + S(18)
|
||
self.d.polygon([(bx - S(1), cb + S(2)), (bx + S(5), cb + S(2)), (bx + S(1), cb + S(8)),
|
||
(bx + S(6), cb + S(8)), (bx - S(4), cb + S(18)), (bx, cb + S(9)),
|
||
(bx - S(4), cb + S(9))], fill=fg)
|
||
|
||
# 线型样式:(实线段长, 间隙长);实线用 dash=0 表示连续
|
||
_STYLES = {"solid": (0, 0), "dashed": (7, 4), "dotted": (1, 2)}
|
||
|
||
def _styled_polyline(self, pts, style, fill=BLACK):
|
||
"""按线型(solid/dashed/dotted)沿折线绘制。点线/虚线靠等距步进采样。"""
|
||
dash, gap = self._STYLES.get(style, (0, 0))
|
||
if dash == 0: # 实线:直接连线
|
||
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
|
||
self.d.line([x0, y0, x1, y1], fill=fill, width=1)
|
||
return
|
||
period, drawn = dash + gap, 0.0 # drawn: 当前所在周期内已走过的距离
|
||
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
|
||
seg = math.hypot(x1 - x0, y1 - y0)
|
||
if seg == 0:
|
||
continue
|
||
ux, uy = (x1 - x0) / seg, (y1 - y0) / seg
|
||
t = 0.0
|
||
while t < seg:
|
||
phase = drawn % period
|
||
if phase < dash: # 处于“实”阶段 → 画一小段
|
||
step = min(dash - phase, seg - t)
|
||
sx, sy = x0 + ux * t, y0 + uy * t
|
||
ex, ey = x0 + ux * (t + step), y0 + uy * (t + step)
|
||
if dash <= 1: # 点线:画方点更醒目
|
||
self.d.point((round(sx), round(sy)), fill=fill)
|
||
else:
|
||
self.d.line([sx, sy, ex, ey], fill=fill, width=1)
|
||
t += step; drawn += step
|
||
else: # 处于“虚”阶段 → 跳过
|
||
step = min(period - phase, seg - t)
|
||
t += step; drawn += step
|
||
|
||
def _style_sample(self, x, y, style, w=22, fill=BLACK):
|
||
"""图例用的线型样例(一小段水平线)。"""
|
||
self._styled_polyline([(x, y), (x + w, y)], style, fill=fill)
|
||
|
||
def _trend_chart(self, y0, trend):
|
||
"""近30天三指标折线图:标题(左) + 紧凑图例(右) + 折线 + X轴(仅月/日)。
|
||
各指标按自身极值独立归一化,故只表达趋势形状,不可横向比绝对值。"""
|
||
dates, series = trend["dates"], trend["series"]
|
||
leg_y = y0
|
||
# ---- 标题(左):■ 近30天趋势 ----
|
||
sq, sq_y = 6, leg_y + 4
|
||
self.d.rectangle([10, sq_y, 10 + sq, sq_y + sq], fill=BLACK)
|
||
self.text(10 + sq + 6, leg_y, "橘喵·30天趋势", self.F12)
|
||
# 无数据:画占位符 "--",跳过图例/折线/X轴
|
||
if not series or not dates or not any(s.get("data") for s in series):
|
||
self.ctext(self.W / 2, leg_y + 36, "--", self.F24)
|
||
return
|
||
# ---- 图例(右):紧凑、整体右对齐,从右往左逐项排布 ----
|
||
SAMP, G1, GAP = 16, 8, 10 # 样例线长 / 线与名间隙 / 图例项间隙
|
||
x = self.W - 10
|
||
for s in reversed(series):
|
||
self.rtext(x, leg_y, s["k"], self.F12) # 指标名右端对齐 x
|
||
x -= self._tw(s["k"], self.F12) + G1
|
||
self._style_sample(x - SAMP, leg_y + 7, s["style"])
|
||
x -= SAMP + GAP
|
||
# ---- 绘图区 ----
|
||
px0, px1 = 14, self.W - 10
|
||
py0, py1 = leg_y + 22, leg_y + 22 + 56 # 顶/底
|
||
self.hdash(px0, px1, py1, dash=2, gap=3) # 基线
|
||
n = len(dates)
|
||
xstep = (px1 - px0) / (n - 1) if n > 1 else 0
|
||
for s in series:
|
||
data = s["data"]
|
||
lo, hi = min(data), max(data)
|
||
span = (hi - lo) or 1
|
||
pad = 4 # 上下留白,避免贴边
|
||
pts = []
|
||
for i, v in enumerate(data):
|
||
x = px0 + xstep * i
|
||
yv = py1 - pad - (v - lo) / span * (py1 - py0 - 2 * pad)
|
||
pts.append((x, yv))
|
||
self._styled_polyline(pts, s["style"])
|
||
# ---- X 轴标签:仅首 / 中 / 末三个日期 ----
|
||
xl_y = py1 + 3
|
||
self.text(px0, xl_y, dates[0], self.F12)
|
||
self.ctext((px0 + px1) / 2, xl_y, dates[n // 2], self.F12)
|
||
self.rtext(px1, xl_y, dates[-1], self.F12)
|
||
|
||
# ---------- 入口 ----------
|
||
def render(self, data):
|
||
"""根据 data 渲染并返回 PIL.Image('L' 模式,纯 0/255)。"""
|
||
self.img = Image.new("L", (self.W, self.H), WHITE)
|
||
self.d = ImageDraw.Draw(self.img)
|
||
self.d.fontmode = "1" # 关闭抗锯齿,点阵锐利
|
||
|
||
dt, wt, mao = data["date"], data["weather"], data["mao"]
|
||
|
||
# A. Hero:大时钟 + 日期(时钟上移使「顶部留白 ≈ 与日期间距」对称)
|
||
self.text(10, 0, dt["time"], self.F48, bold=2)
|
||
self.text(12, 64, f'{dt["week"]} {dt["greg"]}', self.F12)
|
||
|
||
# B. 天气:右对齐文字 + 居中的 28° + 放大右靠的图标
|
||
DIV_Y = 90 # 天气区底部分割线
|
||
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}'
|
||
self.rtext(390, 8, loc, self.F12)
|
||
self.rtext(390, 30, temp, self.F24, bold=1)
|
||
self.rtext(390, 64, cr, self.F12)
|
||
# 定位图标:置于 loc 文字左侧,留间距并与文字竖直居中
|
||
PIN_W, PIN_GAP = 8, 6
|
||
loc_pin_x = 390 - self._tw(loc, self.F12) - PIN_GAP - PIN_W
|
||
self._loc_pin(loc_pin_x, 10, PIN_W)
|
||
s = 1.5
|
||
iw, ih = 36 * s, 38 * s # 图标原生约 36x38
|
||
text_left = min(loc_pin_x,
|
||
390 - self._tw(temp, self.F24) - 1,
|
||
390 - self._tw(cr, self.F12))
|
||
icon_y = (DIV_Y - ih) / 2 # 顶部(0)与分割线正中:上下留白相等
|
||
self._weather_icon(text_left - 12 - iw, icon_y, self._weather_category(wt.get("icon")), s=s)
|
||
|
||
self.hdash(8, 392, DIV_Y)
|
||
|
||
# C. 橘喵今日经营:三列(数值 + 环比/同比)
|
||
self._title_bar(96, "橘喵·今日实时", mao["upd"])
|
||
cols = mao["cols"]; n = len(cols)
|
||
ax0, ax1 = 8, 392
|
||
cw = (ax1 - ax0) / n
|
||
for i in range(1, n):
|
||
self.vdash(ax0 + cw * i, 128, 192)
|
||
for i, c in enumerate(cols):
|
||
cx = ax0 + cw * (i + 0.5)
|
||
self.ctext(cx, 114, c["k"], self.F12)
|
||
self.ctext(cx, 130, c["v"], self.F24, bold=1)
|
||
self._cmp_line(cx, 160, "环比", c["hb"])
|
||
self._cmp_line(cx, 178, "同比", c["tb"])
|
||
|
||
self.hdash(8, 392, 200)
|
||
|
||
# D. 近30天三指标趋势折线图
|
||
self._trend_chart(204, mao["trend"])
|
||
|
||
return self.img
|
||
|
||
def render_to_file(self, data, path):
|
||
img = self.render(data)
|
||
import os
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
img.save(path)
|
||
return path
|