Files
eink-push/renderer.py
YANG JIANKUAN d35f233ef3 fix: 用量行 pace 列真 0 窗口不再缺席,diff=0 三角位画等号
Claude 5h 空闲时接口回 utilization 0、resets_at null,算不出 time_pct,
渲染器整列跳过 pace 导致该行进度条比别行长。数据层对 pct=0 且无重置时刻的
窗口记 time_pct=0;基类 _usage_row 只要有百分比就画 pace(缺 time_pct 按 0)。
diff=0 时三角位由留空改为画等宽等号「=」,预留逻辑从宽版下沉到基类,小屏同样齐平。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:33:21 +08:00

492 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
渲染层 —— 将数据绘制成 400x300 三色(黑/白/红)墨水屏仪表盘。
设计要点:
- 三色 BWR仍无真灰阶灰阶只能抖动成网点弃用底色黑白靠留白 + 大字号 + 一处大红块建层级。
克制而有分量:全图只一个「大红」焦点(用量超阈值的整条红进度条),其余红为小面积语义点缀。
- 关键墨水屏配色铁律:红与黑同属「深色」,红叠黑对比极低 → 红只出现在**白底**(红字/红三角/红块),
**绝不红字压黑底**;白底红块 + 白字最稳。
- Fusion Pixel 点阵字体,按整数倍尺寸(12/24/48)渲染fontmode='1' 关闭抗锯齿 → 像素锐利。
- 重点数据用「伪粗体」(偏移叠绘)加粗,仍对齐像素网格。
布局(自上而下三段,两道横规分隔):
A 顶部:大时钟+日期(左)/ 天气(右),白底黑字 + 红点缀(定位红水滴、太阳/闪电红);
B 中部主角Claude Usage——每条单行 = 左标签(5h/7d,F13) + 进度条 + 百分比(F16,主数字) + 时间维度对比 pace(F13)
pct ≥ 阈值(warn) 时该条进度条(含边框)与百分比整体转红;
pace = 用量% 时间% ,用上/下三角(替代 +/)表示超前/节余,超前(↑)红、节余(↓)黑;
C 底部:橘喵今日实时三列,环比/同比「涨=红·跌=黑」。
近30天趋势图暂隐藏`_trend_chart` 及其依赖 `_styled_polyline`/`_style_sample` 保留、未调用,随时可再挂回)。
红出现处:① 定位水滴 ② 天气太阳/闪电 ③ 用量进度条与百分比(≥warn) ④ pace 超前(↑) ⑤ 环比/同比「涨」的三角与数值。
"""
import math
from PIL import Image, ImageDraw, ImageFont
import config
# 三色墨水屏(BWR)调色板:黑 / 白 / 纯正红。
# RED 用 (255,0,0) 作为设备红通道的明确信号——硬阈值量化下会干净地落到红色,不会被误判为黑。
# 设计准则:红是稀缺强调色,仅用于「方向/状态、警示、寻路锚点」,占墨比例应很小(≈全图 <10%),其余一律黑白。
BLACK, WHITE, RED = (0, 0, 0), (255, 255, 255), (255, 0, 0)
class DashboardRenderer:
# 百分比数字的字符间距(px乘 k)F16 为 16/12x 非整数倍缩放,数字墨迹无侧边距、相邻紧贴,需手动加距;
# 子类若把百分比换成原生 12px 字号(宽版),字形自带侧边距,应覆写为 0。
PCT_TRACK = 2
# 用量行左标签的字重与对齐:三色版 粗体、左对齐(贴 x0宽版覆写为 常规字重、右对齐(贴进度条左侧留 LABEL_GAP
LABEL_BOLD = True
LABEL_ALIGN = "l"
LABEL_GAP = 8
def __init__(self, font_path=config.FONT_PATH, size=(config.WIDTH, config.HEIGHT), k=1):
"""k整体缩放系数整数。k=1 为 400x300 原版k=2 供 800x480 设备reTerminal E1002复用组件
字体按 k 倍创建(点阵字体整数倍最锐利),组件内的像素常量一律乘 k故各组件在两种尺寸下形态一致。
字体属性名仍按 k=1 的字号命名F12/F24/…),表达「层级」而非绝对像素。"""
self.W, self.H = size
self.k = k
self.F12 = ImageFont.truetype(font_path, 12 * k)
self.F13 = ImageFont.truetype(font_path, 13 * k) # 5h/7d 与 pace 用;接近原生 12px畸变很小
self.F16 = ImageFont.truetype(font_path, 16 * k) # 非整数倍(1.33x):靠 fontmode='1' 保持纯色,笔画略不均可接受
self.FPACE = self.F13 # pace 数值字体;与标签解耦,宽版把它换成 24px 而标签仍 12px
self.F24 = ImageFont.truetype(font_path, 24 * k)
self.F36 = ImageFont.truetype(font_path, 36 * k) # 3 倍整数缩放,仍锐利;宽版今日实时数值用
self.F48 = ImageFont.truetype(font_path, 48 * k)
# ---------- 基础绘制工具 ----------
def _tw(self, s, ft, track=0):
# track额外字符间距(px)。非整数倍缩放字号(如 F16=16/12x)下,数字字形墨迹宽度
# 等于步进宽度(无侧边距),相邻字符几乎零间隙,需手动加距才不会紧贴。
if track:
return sum(self.d.textlength(c, font=ft) for c in s) + track * (len(s) - 1)
return self.d.textlength(s, font=ft)
def _draw(self, x, y, s, ft, fill, bold, track=0):
# 伪粗体:点阵字体单一字重,按 1~bold px 偏移叠绘加粗笔画
if not track:
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)
return
cx = x
for ch in s:
self.d.text((cx, y), ch, font=ft, fill=fill)
for dx in range(1, bold + 1):
self.d.text((cx + dx, y), ch, font=ft, fill=fill)
cx += self.d.textlength(ch, font=ft) + track
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, width=1):
x = x0
while x < x1:
self.d.line([x, y, min(x + dash, x1), y], fill=fill, width=width)
x += dash + gap
def vdash(self, x, y0, y1, dash=4, gap=4, fill=BLACK, width=1):
y = y0
while y < y1:
self.d.line([x, y, x, min(y + dash, y1)], fill=fill, width=width)
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_w(self, label, pair):
"""环比/同比一行的总宽度(标签 + 间隙 + 三角 + 数值),供居中/右对齐排布。"""
direction, val = pair
k = self.k
trw, tgap = (8 * k, 4 * k) if direction else (0, 0)
return self._tw(label, self.F12) + 5 * k + trw + tgap + self._tw(val, self.F12)
def _cmp_line(self, cx, y, label, pair, fg=BLACK):
"""环比/同比一行:标签 + 涨跌三角 + 数值,整体居中于 cx。direction 为 None 时不画三角。"""
self._cmp_at(cx - self._cmp_w(label, pair) / 2, y, label, pair, fg)
def _cmp_color(self, direction, fg=BLACK):
"""环比/同比「三角 + 数值」的颜色钩子:三色版 涨红、其余沿用 fg。宽版覆写。"""
return RED if direction == "up" else fg
def _cmp_at(self, x, y, label, pair, fg=BLACK):
"""环比/同比一行,左对齐于 x宽版把它放在数值右侧时用"""
direction, val = pair
k = self.k
lw = self._tw(label, self.F12); gap = 5 * k
trw, tgap = (8 * k, 4 * k) if direction else (0, 0)
self.text(x, y, label, self.F12, fill=fg); x += lw + gap
# 方向语义配色由 _cmp_color 决定(三色版:红涨、跌/无 → 黑;宽版覆写为 涨绿跌红 与 K 线一致);标签始终黑
up = direction == "up"
val_color = self._cmp_color(direction, fg)
if direction:
# 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高
self.tri(x + trw / 2, y + 8.5 * k, 7 * k, up=up, fill=val_color); x += trw + tgap
self.text(x, y, val, self.F12, fill=val_color)
def _label(self, y, title, right=None, x0=None, x1=None):
"""区块标题(两区块统一,对齐主分支 _title_bar 风格):
■ 方块装饰(8px与 F12 标题墨迹竖直居中) + 标题(F12,常规字重) + 可选右侧信息。
x0/x1标题起点 / 右侧信息右端,默认为页边距 10宽版并排两块时可各自指定"""
k = self.k
SQ, M = 8 * k, 10 * k # 方块边长 / 左右页边距
x0 = M if x0 is None else x0
x1 = self.W - M if x1 is None else x1
self.d.rectangle([x0, y + 4 * k, x0 + SQ, y + 4 * k + SQ], fill=BLACK) # 中心≈y+8对齐 F12 墨迹中心(≈y+8.5)
self.text(x0 + SQ + 6 * k, y, title, self.F12)
if right:
self.rtext(x1, y, right, self.F12)
def _mid(self, x, cy, s, ft, fill=BLACK, bold=0, align="l", track=0):
"""按字符串「墨迹竖直中心」对齐到 cy 绘制——跨字号(F12/F24)也能真正居中,
避免不同字号按顶部对齐时的视觉错位。align: l 左对齐 / r 右对齐 / c 居中于 x。
track见 _tw用于给紧贴的字符手动加间距。"""
_, t, _, b = ft.getbbox(s) # 该串墨迹在字身框内的上/下沿
y = cy - (t + b) / 2.0
w = self._tw(s, ft, track)
if align == "r":
x -= w + bold
elif align == "c":
x -= (w + bold) / 2.0
self._draw(x, y, s, ft, fill, bold, track)
def _pace(self, xr, cy, diff):
"""时间维度对比pace = 用量% 时间%>0 超前↑红;<0 节余↓黑;=0 持平。
数值(FPACE默认 F13)两位补零、右端对齐 xr小三角(6)紧贴其左;均竖直居中 cy。返回整体宽度供左侧定位。
diff=0持平/无数据)三角位画一个与三角等宽的等号「=」2026-09-14 用户要求,原为留空,后曾用「-」——pace 列每行等宽,各行进度条右端齐平。"""
up = diff > 0
color = self._pace_color(diff)
txt = f"{abs(int(diff)):02d}"
tw = self._tw(txt, self.FPACE)
self._mid(xr, cy, txt, self.FPACE, fill=color, bold=self.k, align="r")
TRI_GAP, TRI = 4 * self.k, 6 * self.k
cx = xr - tw - TRI_GAP - TRI / 2
if diff != 0:
self.tri(cx, cy, TRI, up=up, fill=color)
else:
k = self.k # 等号「=」:两道横杠,各宽 6k、厚 k中间隔 2k整体以 cy 为中心
for top in (cy - 2 * k, cy + k):
self.d.rectangle([cx - TRI / 2, top, cx + TRI / 2 - 1, top + k - 1], fill=color)
return tw + TRI_GAP + TRI
def _usage_row(self, x0, x1, y, bar, warn, label_w=None):
"""一条用量(单行):左标签(5h/7d, F13) + 进度条 + 百分比(F16, 主数字) + 时间维度对比 pace(F13)。
右侧从右往左排布显式控制两处横向间距G1 进度条↔百分比、G2 百分比↔pace全部竖直居中 cy。
pct ≥ warn → 进度条(含整条边框)与百分比转红;边框随条色,红条即红框,无黑白错位。
label_w标签列宽默认 26*k够放 5h/7d宽版有 "Fable 7d" 这类长标签时由调用方按最长标签算好传入。"""
raw = bar.get("pct")
ok = isinstance(raw, (int, float)) # None/缺失 → 显示 "--"
pct = max(0, min(100, int(raw))) if ok else 0
fill, outline, color = self._usage_colors(ok, pct, warn, bar)
k = self.k
LABEL_W = 26 * k if label_w is None else label_w
G1, G2, H = 13 * k, 8 * k, 15 * k # G1 进度条↔百分比、G2 百分比↔pace视觉约 12/11
PCT_TRACK = self.PCT_TRACK * k # 见类属性 PCT_TRACK
cy = y + 10 * k # 行竖直中心
lb = k if self.LABEL_BOLD else 0 # 左标签 F13
if self.LABEL_ALIGN == "r":
self._mid(x0 + LABEL_W - self.LABEL_GAP * k, cy, bar["k"], self.F13, bold=lb, align="r")
else:
self._mid(x0, cy, bar["k"], self.F13, bold=lb)
# 右侧从右往左pace(最右) → 百分比 → 进度条
pct_r = x1
if ok:
# 有百分比就必画 pace 列time_pct 缺失(窗口无重置时刻)按 0 差值画 "00",不能整列省掉——否则该行进度条会变长、右端错位
pace_w = self._pace(x1, cy, pct - int(bar["time_pct"]) if "time_pct" in bar else 0)
pct_r = x1 - pace_w - G2
pct_txt = f"{pct:02d}%" if ok else "--"
self._mid(pct_r, cy, pct_txt, self.F16, fill=color, bold=k, align="r", track=PCT_TRACK) # 百分比 F16
bx0 = x0 + LABEL_W
bx1 = pct_r - self._tw(pct_txt, self.F16, PCT_TRACK) - G1
by = cy - H // 2
self.d.rectangle([bx0, by, bx1, by + H], outline=outline, width=k) # 轨道边框随条色
fw = (bx1 - bx0) * pct / 100.0
if fw >= 2:
self.d.rectangle([bx0, by, bx0 + fw, by + H], fill=fill)
return bx0, bx1, by, H # 进度条几何(宽版合并行据此在边框内重绘上下两条实心填充)
def _usage_colors(self, ok, pct, warn, bar):
"""用量行配色钩子 → 进度条填充轨道边框百分比文字。三色版≥warn 整体红,否则黑。"""
c = RED if (ok and pct >= warn) else BLACK
return c, c, c
def _pace_color(self, diff):
"""pace用量% 时间%)配色钩子。三色版:>0 超前红,否则黑。"""
return RED if diff > 0 else BLACK
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, accent=None, wet=None):
"""按天气类别画图标s 为缩放系数,原生约 36x38左上角 x,y
accent 用于「有能量」的元素——太阳、闪电——着红wet 用于雨滴/雪花(六色版给蓝),默认随 fg
云/雾/月保持 fg"""
S = lambda v: v * s
accent = accent or fg
wet = wet or fg
if category == "sunny":
self._sun(x + S(18), y + S(19), S(11), s, accent)
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, accent) # 探出的太阳着红
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=wet, 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=wet, 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=accent) # 闪电着红作警示
# 线型样式:(实线段长, 间隙长);实线用 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):
c = RED if s.get("k") == "流水" else BLACK # 主指标(流水)图例同步着红
self.rtext(x, leg_y, s["k"], self.F12, fill=c) # 指标名右端对齐 x
x -= self._tw(s["k"], self.F12) + G1
self._style_sample(x - SAMP, leg_y + 7, s["style"], fill=c)
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:
color = RED if s.get("k") == "流水" else BLACK # 主指标(流水)为主角,着红领读
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"], fill=color)
if color == RED and pts: # 主指标最新值标红点:“今日 · 你在此处”
ex, ey = pts[-1]
self.d.ellipse([ex - 2.5, ey - 2.5, ex + 2.5, ey + 2.5], fill=RED)
# ---- 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'RGB' 模式,仅黑/白/红三色)。
版式(自上而下三段,两道横规分隔):
A 顶部:大时钟+日期(左)/ 天气(右)——白底黑字,红点缀(定位/太阳闪电)
B 中部主角Claude Code 用量——每条=标题行 + 一条粗进度条pct≥阈值转红预警
C 底部:橘喵今日实时三列(涨=红·跌=黑。近30天趋势图暂隐藏(_trend_chart 保留)。
"""
self.img = Image.new("RGB", (self.W, self.H), WHITE)
self.d = ImageDraw.Draw(self.img)
self.d.fontmode = "1" # 关闭抗锯齿点阵锐利RGB 上仍生效,纯色描边不发糊)
dt, wt, mao, usage = data["date"], data["weather"], data["mao"], data["usage"]
# ---- A. 顶部:大时钟+日期(左)/ 天气(右)。白底黑字,红点缀 ----
self.text(10, 0, dt["time"], self.F48, bold=2)
self.text(12, 64, f'{dt["week"]} {dt["greg"]}', self.F12)
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)
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, fg=RED) # 定位红水滴
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))
self._weather_icon(text_left - 12 - iw, (DIV_Y - ih) / 2,
self._weather_category(wt.get("icon")), s=s, accent=RED)
self.hdash(8, 392, DIV_Y)
# ---- B. 中部主角Claude Usage单行标签 + 进度条 + 百分比 + pace≥阈值转红----
# 标题贴近上分割线(与今日实时一致的 6px 间距);两条进度条在余下空间内等距(≈19px)
self._label(96, usage.get("title", "Claude Usage"))
warn = usage.get("warn", 80)
ry = 123
for b in usage.get("bars", []):
if b.get("scoped"): # 按模型限定的额度Fable 7d只在 800x480 宽版展示,小屏放不下第三行
continue
self._usage_row(10, 390, ry, b, warn)
ry += 31 # 略压缩两条间距,给底部让出下边距
self.hdash(8, 392, 187) # B/C 分割线上移,最终给底部今日实时留出与整图下边的间距
# ---- C. 底部:橘喵·今日实时(三列,版式对齐主分支:标题+18=名/+34=值/+64=环比/+82=同比)----
self._label(193, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}')
cols = [c for c in mao["cols"] if not c.get("wide")]; n = len(cols) # 仅宽版的列(团购)小屏放不下,跳过
ax0, ax1 = 8, 392
cw = (ax1 - ax0) / n
for i in range(1, n):
self.vdash(ax0 + cw * i, 225, 289)
for i, c in enumerate(cols):
cx = ax0 + cw * (i + 0.5)
self.ctext(cx, 211, c["k"], self.F12) # 指标名(标题+18
self.ctext(cx, 227, c["v"], self.F24, bold=1) # 数值 伪粗体(+34
self._cmp_line(cx, 257, "环比", c["hb"]) # +64
self._cmp_line(cx, 275, "同比", c["tb"]) # +82墨迹止于≈289底部留≈10px
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