#!/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