feat: E1002 六色版改为 k=1 原字号重排版+启用黄绿蓝语义色并恢复 30 天趋势图

- 放弃 k=2 整数倍放大(字太大),字号与 400×300 三色版一致,靠四倍面积重排:顶部时钟/天气不变,中段 Claude Usage 与橘喵今日实时左右并排,下段全宽恢复三色版隐藏的 30 天趋势图
- 六色各有唯一语义:红=预警/涨/定位/太阳闪电/流水线,黄=用量「注意」档填充(USAGE_CAUTION,默认 60,仅六色版),绿=毛利线,蓝=单量线与雨滴雪花
- renderer 基类新增 _usage_colors 配色钩子与 _weather_icon 的 wet 参数、_section 支持自定义横向范围,三色版行为不变
- data 为趋势追加 totals 合计(现算不进缓存),usage 增加 caution 阈值
- 底部六色测试色条改由 E1002_COLOR_STRIP 控制,默认关闭;同步更新 CLAUDE.md

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 14:11:35 +08:00
parent eab90bf3b5
commit d7ae193f56
5 changed files with 184 additions and 86 deletions

View File

@@ -1,22 +1,26 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的全部组件(k=2 整数倍缩放
重新排版为 800x480输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图,
reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的全部组件(**k=1字号与 400x300 版完全相同**
利用 800x480 的四倍面积重新排版,输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图,
并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。
为什么是「固定 800x480 + 一张 PNG」而不是自适应 HTML
- SenseCraft 的 HTML 控件是云端无头浏览器截图后再量化到六色下发设备。自适应布局在未知视口/DPR 下
会被重排、缩放、抗锯齿,产生大量中间色,最终被平台抖动成网点——恰是我们最不想要的
- 页面锁死为 800x480、内容就是一张 1:1 的 800x480 PNG`image-rendering: pixelated`
只要控件铺满画布、视口 ≥ 800x480截图即与 PNG 逐像素一致;每个像素本来就是六色之一,量化零损失
- 所有版式/字体/图标仍由 Pillow 在像素网格上控制(点阵字体 + fontmode='1'),与 400x300 版一致锐利。
版式k=112/24/48px 点阵字体,不放大)
A 顶部090 :大时钟+日期(左)|天气(右,贴 790——与三色版同一套组件与尺寸。
B 中段96192 :左半 Claude Usage10390竖规 x=400右半 橘喵·今日实时三列410790
C 下段206462橘喵·30 天趋势(全宽,三指标各一色:流水红 / 单量蓝 / 毛利绿),三色版里隐藏的图在此恢复。
D 可选470479六色测试色条config.E1002_COLOR_STRIP默认关真机校准色彩量化时打开
六色使用原则(比三色版更克制,不为「有」用):
- 黑/白 承担全部结构与正文;红 延续三色版语义(预警、涨、定位、太阳/闪电
- 黄/绿/蓝 目前**只出现在底部测试色条**,用于在真机上校准平台量化是否落到原生色
后续若要启用(如 绿=节余/跌、蓝=雨雪),先在真机看过色条效果再决定
六色的语义分工(每种颜色只有一个含义,不为「有」用):
- 黑/白:结构与正文。红:预警、涨、定位、太阳/闪电、趋势主指标(流水)。
- 黄用量进度条「注意」档caution ≤ pct < warn填充边框与百分比仍黑黄字压白底不可读故黄只作填充
- 绿:趋势「毛利」线。蓝:趋势「单量」线、天气雨滴/雪花
- 整图只允许六种纯色;渲染后可用 `img.getcolors()` 自查。
为什么是「固定 800x480 + 一张 PNG」而不是自适应 HTML
- SenseCraft 的 HTML 控件是云端无头浏览器截图后再量化到六色下发。自适应布局在未知视口/DPR 下会被重排、
缩放、抗锯齿,产生大量中间色,最终被平台抖动成网点。页面锁死 800x480、内容就是 1:1 的 PNG
`image-rendering: pixelated`),控件铺满画布即逐像素一致,每个像素本就是六色之一,量化零损失。
"""
import os
import time
@@ -28,18 +32,32 @@ from renderer import DashboardRenderer, BLACK, WHITE, RED
YELLOW, GREEN, BLUE = (255, 255, 0), (0, 255, 0), (0, 0, 255)
SPECTRA6 = [BLACK, WHITE, YELLOW, RED, GREEN, BLUE]
# 趋势三指标配色:主指标流水红领读,单量蓝、毛利绿;未列出的指标回退黑
SERIES_COLOR = {"流水": RED, "单量": BLUE, "毛利": GREEN}
class E1002Renderer(DashboardRenderer):
"""800x480 六色版。组件全部继承k=2只重写 `render()` 的版式"""
"""800x480 六色版。组件全部继承k=1字号不变只重写版式与配色钩子"""
def __init__(self, font_path=config.FONT_PATH):
super().__init__(font_path, size=(config.E1002_WIDTH, config.E1002_HEIGHT), k=2)
super().__init__(font_path, size=(config.E1002_WIDTH, config.E1002_HEIGHT), k=1)
# ---------- 配色钩子 ----------
def _usage_colors(self, ok, pct, warn, bar):
"""三档≥warn 整体红caution≤pct<warn 黄填充+黑框黑字|其余黑。"""
if not ok:
return BLACK, BLACK, BLACK
if pct >= warn:
return RED, RED, RED
if pct >= self._caution:
return YELLOW, BLACK, BLACK
return BLACK, BLACK, BLACK
# ---------- 组件 ----------
def _color_strip(self, y0, y1, x0=None, x1=None):
"""底部六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。"""
k = self.k
x0 = 10 * k if x0 is None else x0
x1 = self.W - 10 * k if x1 is None else x1
"""六色测试色条:六块等宽实心色块,白块加 1px 黑框以便可见。真机校准用。"""
x0 = 10 if x0 is None else x0
x1 = self.W - 10 if x1 is None else x1
n = len(SPECTRA6)
w = (x1 - x0) / n
for i, c in enumerate(SPECTRA6):
@@ -48,75 +66,129 @@ class E1002Renderer(DashboardRenderer):
if c == WHITE:
self.d.rectangle([bx0, y0, bx1, y1], outline=BLACK, width=1)
def _trend_chart6(self, y0, y1, trend):
"""近30天三指标折线六色版标题+ 彩色图例(右)+ 折线 + X 轴。
与三色版 `_trend_chart` 的差别:用**颜色**而非线型区分指标(全部实线,主指标 2px
绘图区高度由 y0y1 决定以填满下半屏。各指标按自身极值独立归一化,只表达趋势形状。"""
dates, series = trend.get("dates") or [], trend.get("series") or []
title = "橘喵·30天趋势"
self._label(y0, title)
if not series or not dates or not any(s.get("data") for s in series):
self.ctext(self.W / 2, (y0 + y1) / 2 - 12, "--", self.F24)
return
# ---- 合计(紧随标题):「合计 单量 12,345 流水 123.45万 毛利 12.34万」,数值伪粗体 ----
totals = trend.get("totals") or []
if totals:
x = 10 + 8 + 6 + self._tw(title, self.F12) + 24
self.text(x, y0, "合计", self.F12); x += self._tw("合计", self.F12) + 12
for name, val in totals:
self.text(x, y0, name, self.F12); x += self._tw(name, self.F12) + 4
self.text(x, y0, val, self.F12, bold=1); x += self._tw(val, self.F12) + 1 + 14
# ---- 图例(右):色块样线 + 指标名,从右往左排布 ----
SAMP, G1, GAP = 16, 8, 12
x = self.W - 10
for s in reversed(series):
c = SERIES_COLOR.get(s.get("k"), BLACK)
self.rtext(x, y0, s["k"], self.F12, fill=c)
x -= self._tw(s["k"], self.F12) + G1
self.d.line([x - SAMP, y0 + 7, x, y0 + 7], fill=c, width=2)
x -= SAMP + GAP
# ---- 绘图区 ----
px0, px1 = 14, self.W - 10
py0, py1 = y0 + 24, y1 - 16 # 底部留 16px 给 X 轴日期
self.hdash(px0, px1, py1, dash=2, gap=3) # 基线
n = len(dates)
xstep = (px1 - px0) / (n - 1) if n > 1 else 0
# 周界竖向点规:每 7 个点一道,帮助读「第几周」,不与折线争抢
for i in range(7, n, 7):
self.vdash(px0 + xstep * i, py0, py1, dash=1, gap=3)
# 先画次要指标,最后画主指标(流水)压顶
order = sorted(series, key=lambda s: s.get("k") == "流水")
for s in order:
color = SERIES_COLOR.get(s.get("k"), BLACK)
data = s["data"]
lo, hi = min(data), max(data)
span = (hi - lo) or 1
pad = 6
pts = []
for i, v in enumerate(data):
xv = px0 + xstep * i
yv = py1 - pad - (v - lo) / span * (py1 - py0 - 2 * pad)
pts.append((xv, yv))
w = 2 if color == RED else 1
for (ax, ay), (bx, by) in zip(pts, pts[1:]):
self.d.line([ax, ay, bx, by], fill=color, width=w)
if color == RED and pts: # 主指标最新值标点:“今日 · 你在此处”
ex, ey = pts[-1]
self.d.ellipse([ex - 3, ey - 3, ex + 3, ey + 3], 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):
"""800x480 版式(自上而下):
字体属性沿用 k=1 的层级名F12/F24/F48k=2 下实际为 24/48/96px。
A 顶部0112大时钟F48+ 同行右侧日期F12定位·温度F24·天气/温区F12+ 矢量图标s=2.0
B 中部124258Claude Usage 标题 + 两条用量行k=2 组件原样放大)
C 底部272440橘喵今日实时三列名 F12 / 值 F24 粗 / 环比·同比 F12
D 色条452480Spectra 6 六色测试块
"""
from PIL import Image, ImageDraw
self.img = Image.new("RGB", (self.W, self.H), WHITE)
self.d = ImageDraw.Draw(self.img)
self.d.fontmode = "1"
k = self.k
M = 10 * k # 页边距
L, R = M, self.W - M # 内容左右边界 20 / 780
R = self.W - 10 # 右页边 790
dt, wt, mao, usage = data["date"], data["weather"], data["mao"], data["usage"]
self._caution = usage.get("caution", config.USAGE_CAUTION)
# ---- A. 顶部:时钟 + 日期(左,同一基线/ 天气(右)----
DIV_Y = 112
clock = dt["time"]
self.text(L, 4, clock, self.F48, bold=2 * k)
cw = self._tw(clock, self.F48) + 2 * k
# 日期紧随时钟右侧按墨迹底沿对齐时钟数字底沿F48 字身框内数字底沿≈ y+4+ 字号*0.83
_, _, _, cb = self.F48.getbbox(clock)
_, _, _, db = self.F12.getbbox("2026/09/09")
self.text(L + cw + 12 * k, 4 + cb - db, f'{dt["week"]} {dt["greg"]}', self.F12)
# ---- A. 顶部:时钟+日期(左)/ 天气(右)——与三色版同尺寸 ----
DIV_Y = 90
self.text(10, 0, dt["time"], self.F48, bold=2)
self.text(12, 64, f'{dt["week"]} {dt["greg"]}', self.F12)
loc, temp, cr = wt["loc"], wt["temp"], f'{wt["cond"]} {wt["range"]}'
self.rtext(R, 8, loc, self.F12)
self.rtext(R, 34, temp, self.F24, bold=k)
self.rtext(R, 82, cr, self.F12)
PIN_W, PIN_GAP = 8 * k, 6 * k
self.rtext(R, 30, temp, self.F24, bold=1)
self.rtext(R, 64, cr, self.F12)
PIN_W, PIN_GAP = 8, 6
loc_pin_x = R - self._tw(loc, self.F12) - PIN_GAP - PIN_W
self._loc_pin(loc_pin_x, 12, PIN_W, fg=RED)
s = 2.0 # 图标标称 36x38雷阵雨含闪电实高 46s=2 时 92px 仍落在 DIV_Y 之上,再大会压线
self._loc_pin(loc_pin_x, 10, PIN_W, fg=RED)
s = 1.5
iw, ih = 36 * s, 38 * s
text_left = min(loc_pin_x, R - self._tw(temp, self.F24) - k, R - self._tw(cr, self.F12))
self._weather_icon(text_left - 14 * k - iw, (DIV_Y - ih) / 2,
self._weather_category(wt.get("icon")), s=s, accent=RED)
self.hdash(8 * k, self.W - 8 * k, DIV_Y, dash=4 * k, gap=4 * k, width=k)
text_left = min(loc_pin_x, R - self._tw(temp, self.F24) - 1, R - 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, wet=BLUE)
self.hdash(8, self.W - 8, DIV_Y)
# ---- B. 中部主角:Claude Usage ----
self._label(DIV_Y + 12, usage.get("title", "Claude Usage"))
# ---- B. 中段:左 Claude Usage 右 橘喵·今日实时 ----
MID = self.W // 2 # 400
self._label(96, usage.get("title", "Claude Usage"))
warn = usage.get("warn", 80)
ry = DIV_Y + 48
ry = 123
for b in usage.get("bars", []):
self._usage_row(L, R, ry, b, warn)
ry += 54
DIV2_Y = 266
self.hdash(8 * k, self.W - 8 * k, DIV2_Y, dash=4 * k, gap=4 * k, width=k)
self._usage_row(10, MID - 10, ry, b, warn)
ry += 31
self.vdash(MID, 96, 194)
# ---- C. 底部:橘喵·今日实时 三列 ----
ly = DIV2_Y + 10
self._label(ly, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}')
lx0, lx1 = MID + 10, R
self._label(96, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}', x0=lx0, x1=lx1)
cols = mao["cols"]; n = len(cols)
ax0, ax1 = 8 * k, self.W - 8 * k
ax0, ax1 = MID + 8, self.W - 8
cw = (ax1 - ax0) / n
for i in range(1, n):
self.vdash(ax0 + cw * i, ly + 28, ly + 166, dash=4 * k, gap=4 * k, width=k)
self.vdash(ax0 + cw * i, 128, 192)
for i, c in enumerate(cols):
cx = ax0 + cw * (i + 0.5)
self.ctext(cx, ly + 32, c["k"], self.F12) # 指标名
self.ctext(cx, ly + 60, c["v"], self.F24, bold=k) # 数值 伪粗体
self._cmp_line(cx, ly + 110, "环比", c["hb"])
self._cmp_line(cx, ly + 138, "同比", c["tb"])
self.ctext(cx, 114, c["k"], self.F12) # 指标名(标题+18
self.ctext(cx, 130, c["v"], self.F24, bold=1) # 数值 伪粗体+34
self._cmp_line(cx, 160, "环比", c["hb"]) # +64
self._cmp_line(cx, 178, "同比", c["tb"]) # +82
DIV2_Y = 200
self.hdash(8, self.W - 8, DIV2_Y)
# ---- D. 六色测试色条 ----
self._color_strip(self.H - 28, self.H - 1)
# ---- C. 下段:近 30 天趋势(全宽)----
strip = config.E1002_COLOR_STRIP
self._trend_chart6(DIV2_Y + 6, self.H - (18 if strip else 8), mao.get("trend") or {})
# ---- D. 可选:六色测试色条 ----
if strip:
self._color_strip(self.H - 10, self.H - 1)
return self.img