feat: 渲染升级为黑/白/红三色,红作稀缺强调点缀

新设备在黑白外支持红色。将 renderer 画布从 'L' 灰度改为 'RGB' 三色,
RED=(255,0,0) 作为设备红通道的明确信号;fontmode='1' 保持点阵纯色不发糊。

红只承载语义、占墨极小(实测占墨迹 <10%),点缀四处:
- 定位图标(红水滴)
- 天气图标的太阳与闪电(云/雨/雪/雾/月仍黑)
- 环比/同比「涨」的三角与百分比数值(红涨;跌/标签仍黑)
- 趋势图主指标(流水)折线 + 末点红点 + 图例同步红

整图仅三种纯色,无灰阶、不抖动;dither 保持 false。
同步更新 CLAUDE.md / README.md 的三色约束与红点缀清单。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 18:19:38 +08:00
parent 3e0e6c73a1
commit 9b39eacecf
3 changed files with 52 additions and 28 deletions

View File

@@ -6,7 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
`eink-push` 是 JM monorepo 中一个**独立的 Python 工具**,与其余 Java/Vue 子项目技术栈无关,不受父级 CLAUDE.md 的 jm-cloud/uniapp/admin 开发约束。 `eink-push` 是 JM monorepo 中一个**独立的 Python 工具**,与其余 Java/Vue 子项目技术栈无关,不受父级 CLAUDE.md 的 jm-cloud/uniapp/admin 开发约束。
作用把橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 纯黑白1bit** PNG推送到 Zectrix 极趣云墨水屏设备`zectrix-s3-epaper-4.2`。纯 Python + Pillow**无浏览器依赖**,适合 cron 定时运行。 作用把橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 三色(黑/白/红BWR** PNG推送到 Zectrix 极趣云墨水屏设备。纯 Python + Pillow**无浏览器依赖**,适合 cron 定时运行。
> **分支 `feature/colorful`**:新设备在黑白之外支持红色。本分支把渲染从纯黑白升级为黑/白/红三色,红作为**稀缺强调色**只承载语义(方向·警示·强调),占墨极小(实测红≈全图 0.6%、占墨迹 <10%)。仍**无真灰阶、不抖动**。
## 命令 ## 命令
@@ -36,14 +38,17 @@ ZECTRIX_DEVICE_ID=AA:BB:CC:DD:EE:FF python3 main.py
- **两套文件缓存**(为 cron 每分钟独立进程而设,必须落盘):天气缓存 `.weather_cache.json`TTL=`WEATHER_TTL`,默认 15min未过期不发请求趋势缓存 `.trend_cache.json`(按日期 key当天命中即不再调 `recent-days`,每天只拉一次)。两者**拉取失败均沿用旧缓存**避免整片 `"--"`,仍无缓存才回退占位。两个缓存文件已在 `.gitignore` - **两套文件缓存**(为 cron 每分钟独立进程而设,必须落盘):天气缓存 `.weather_cache.json`TTL=`WEATHER_TTL`,默认 15min未过期不发请求趋势缓存 `.trend_cache.json`(按日期 key当天命中即不再调 `recent-days`,每天只拉一次)。两者**拉取失败均沿用旧缓存**避免整片 `"--"`,仍无缓存才回退占位。两个缓存文件已在 `.gitignore`
- **`jm_api.py`** — jm-devops 后端统计接口客户端,**设备密钥授权**`grant_type=client_secret`,免验证码)。先用 `CLIENT_ID + CLIENT_SECRET``POST /auth/login` 换 token进程内缓存、按 `expire_in` 提前刷新),再带 `Authorization: Bearer <token>` + `clientid` 头调 `/system/statistics/{realtime,recent-days}`;遇 401 清 token 重登一次重试。token 字段为蛇形 `access_token`/`expire_in`。注:历史上曾免鉴权直调、更早曾用账号密码+验证码 OCR 登录ddddocr 不稳定),现统一为 client_secret 授权——密钥即长期凭证,务必走 HTTPS、优先用 `JM_CLIENT_SECRET` 环境变量、可在后端 `sys_client` 轮换。 - **`jm_api.py`** — jm-devops 后端统计接口客户端,**设备密钥授权**`grant_type=client_secret`,免验证码)。先用 `CLIENT_ID + CLIENT_SECRET``POST /auth/login` 换 token进程内缓存、按 `expire_in` 提前刷新),再带 `Authorization: Bearer <token>` + `clientid` 头调 `/system/statistics/{realtime,recent-days}`;遇 401 清 token 重登一次重试。token 字段为蛇形 `access_token`/`expire_in`。注:历史上曾免鉴权直调、更早曾用账号密码+验证码 OCR 登录ddddocr 不稳定),现统一为 client_secret 授权——密钥即长期凭证,务必走 HTTPS、优先用 `JM_CLIENT_SECRET` 环境变量、可在后端 `sys_client` 轮换。
- **`weather_api.py`** — 和风天气QWeather客户端。用**用户专属 API Host**`QWEATHER_HOST`+ `X-QW-Api-Key` 头鉴权;响应 **gzip 压缩**(按 magic number 手动解压)、返回 `code` 为**字符串**。`get_now()` 取实时(`now.text` 中文天气 / `now.temp` / `now.icon` 图标代码),`get_today()``/3d``daily[0]` 今日温区。`QWEATHER_HOST`/`QWEATHER_KEY` 未配置时直接抛错→天气区显示 `"--"` - **`weather_api.py`** — 和风天气QWeather客户端。用**用户专属 API Host**`QWEATHER_HOST`+ `X-QW-Api-Key` 头鉴权;响应 **gzip 压缩**(按 magic number 手动解压)、返回 `code` 为**字符串**。`get_now()` 取实时(`now.text` 中文天气 / `now.temp` / `now.icon` 图标代码),`get_today()``/3d``daily[0]` 今日温区。`QWEATHER_HOST`/`QWEATHER_KEY` 未配置时直接抛错→天气区显示 `"--"`
- **`renderer.py`** — `DashboardRenderer.render(data)` 返回 PIL `'L'` 模式纯 0/255 图;`render_to_file()` 落盘。布局硬编码为四区render 内 A/B/C/DA 大时钟+日期 / B 天气 / C 橘喵今日三列 / D 近30天趋势折线图。趋势线用 `_styled_polyline()` 按 solid/dashed/dotted 区分(设备无颜色),各指标按自身极值独立归一化(故只看趋势形状、不可横向比绝对值)。 - **`renderer.py`** — `DashboardRenderer.render(data)` 返回 PIL `'RGB'` 模式、**仅黑/白/红三色**图;`render_to_file()` 落盘。布局硬编码为四区render 内 A/B/C/DA 大时钟+日期 / B 天气 / C 橘喵今日三列 / D 近30天趋势折线图。趋势线用 `_styled_polyline()` 按 solid/dashed/dotted 区分,各指标按自身极值独立归一化(故只看趋势形状、不可横向比绝对值)。
- **红色点缀系统**(改配色从这里动,务必保持克制):模块顶部定义 `BLACK/WHITE/RED` 三元组,`RED=(255,0,0)` 是给设备红通道的明确信号。红只用在四处:① 定位水滴(`_loc_pin(fg=RED)`)② 天气图标的太阳与闪电(`_weather_icon(accent=RED)`,云/雨/雪/雾/月仍黑)③ 环比/同比**涨=红·跌=黑**——箭头与百分比数值一起着色(`_cmp_line`,「环比/同比」标签仍黑,红涨绿跌之「红涨」)④ 趋势图**主指标(流水)**折线+末点红点(按 `k=="流水"` 命中,图例同步红)。加红先问「是否承载语义」,避免红蔓延稀释注意力。
- **天气图标全矢量手绘**(无图片资源):`_weather_category()` 把和风 icon 代码归并为 8 类sunny/clear_night/cloudy/overcast/thunder/rain/snow/fog识别不了回退 cloudy再由 `_weather_icon()``_sun`/`_cloud` 等基元按 1bit 描边画出;改天气展示从这里动。 - **天气图标全矢量手绘**(无图片资源):`_weather_category()` 把和风 icon 代码归并为 8 类sunny/clear_night/cloudy/overcast/thunder/rain/snow/fog识别不了回退 cloudy再由 `_weather_icon()``_sun`/`_cloud` 等基元按 1bit 描边画出;改天气展示从这里动。
- **`pusher.py`** — `push_image()` 走 Zectrix API`POST /devices/{id}/display/image`,手写 multipart。设备 ID 直接取 `config.DEVICE_ID`(即 `.env``ZECTRIX_DEVICE_ID` 硬件 MAC`resolve_device_id()` 仅校验非空、未配置即报错——**已废弃获取设备列表再取第一个的逻辑**(原 `get_devices()` 已删除,不再调 `GET /devices`)。 - **`pusher.py`** — `push_image()` 走 Zectrix API`POST /devices/{id}/display/image`,手写 multipart。设备 ID 直接取 `config.DEVICE_ID`(即 `.env``ZECTRIX_DEVICE_ID` 硬件 MAC`resolve_device_id()` 仅校验非空、未配置即报错——**已废弃获取设备列表再取第一个的逻辑**(原 `get_devices()` 已删除,不再调 `GET /devices`)。
- **`config.py`** — 顶部 `_load_dotenv()` 先加载同目录 `.env`,再定义 API、设备、画布尺寸、字体路径、推送参数凭证 fallback 为空串,真实值来自 `.env`/环境变量。 - **`config.py`** — 顶部 `_load_dotenv()` 先加载同目录 `.env`,再定义 API、设备、画布尺寸、字体路径、推送参数凭证 fallback 为空串,真实值来自 `.env`/环境变量。
## 墨水屏渲染约束(改 renderer.py 必读) ## 墨水屏渲染约束(改 renderer.py 必读)
设备是 **1bit、无真灰阶**——灰阶只能抖动成网点(难看),所以全程纯黑白,靠构图/字号/留白建立层级,**不要引入灰色填充或抖动** 设备是 **三色(黑/白/红)、无真灰阶**——灰阶只能抖动成网点(难看),所以底色只用黑白 + 红作稀缺强调,靠构图/字号/留白建立层级,**不要引入灰色填充或抖动**
- **红只作语义强调**:方向(涨)、警示(闪电)、强调(定位/主折线);红占墨保持很小(<10% 墨迹滥用会稀释注意力破坏层级整图**只允许三种纯色** `(0,0,0)/(255,255,255)/(255,0,0)`——渲染后可用 `img.getcolors()` 自查出现第四种色多为抗锯齿灰边即为 bug矢量图元(line/ellipse/polygon/arc)默认不抗锯齿故为纯色文字靠 `fontmode="1"` 保持纯色
- 字体固定用 **Fusion Pixel 12px 点阵字体**`fonts/`OFL 协议只按整数倍尺寸 **12/24/48** 渲染`F12/F24/F48`并设 `d.fontmode = "1"` 关闭抗锯齿否则像素会糊 - 字体固定用 **Fusion Pixel 12px 点阵字体**`fonts/`OFL 协议只按整数倍尺寸 **12/24/48** 渲染`F12/F24/F48`并设 `d.fontmode = "1"` 关闭抗锯齿否则像素会糊
- 加粗用伪粗体」:`_draw()` 1~bold px 水平偏移叠绘笔画点阵字体只有单一字重)。 - 加粗用伪粗体」:`_draw()` 1~bold px 水平偏移叠绘笔画点阵字体只有单一字重)。

View File

@@ -1,10 +1,13 @@
# 墨水屏仪表盘推送eink-push # 墨水屏仪表盘推送eink-push
将橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 纯黑白**图片, 将橘喵今日经营、近30天趋势、日期与天气合成为一张 **400×300 三色(黑/白/红BWR**图片,
推送到 Zectrix 极趣云墨水屏设备`zectrix-s3-epaper-4.2`1bit 推送到 Zectrix 极趣云墨水屏设备。
纯 Python + Pillow + Fusion Pixel 点阵字体,**无浏览器依赖**,适合后台/定时运行。 纯 Python + Pillow + Fusion Pixel 点阵字体,**无浏览器依赖**,适合后台/定时运行。
> **`feature/colorful` 分支**:新设备支持红色。红作为**稀缺强调色**只点缀四处——定位图标、
> 天气的太阳/闪电、环比同比「涨」的三角与数值(红涨)、趋势图主指标(流水)折线——占墨极小,其余仍黑白。
## 效果 ## 效果
- 顶部:大号时钟 + 日期 天气(矢量手绘图标 + 温度 + 天气/温区) - 顶部:大号时钟 + 日期 天气(矢量手绘图标 + 温度 + 天气/温区)
@@ -20,7 +23,7 @@ eink-push/
├── data.py # 数据层:拉真实数据并映射,失败回退占位 "--"(不回退 mock含天气/趋势缓存 ├── data.py # 数据层:拉真实数据并映射,失败回退占位 "--"(不回退 mock含天气/趋势缓存
├── jm_api.py # jm-devops 后端统计接口客户端(免登录 GET仅带 clientid 头) ├── jm_api.py # jm-devops 后端统计接口客户端(免登录 GET仅带 clientid 头)
├── weather_api.py # 和风天气QWeather客户端实时天气 + 今日温区) ├── weather_api.py # 和风天气QWeather客户端实时天气 + 今日温区)
├── renderer.py # 渲染层DashboardRenderer 生成 400×300 纯黑白图片 ├── renderer.py # 渲染层DashboardRenderer 生成 400×300 三色(黑/白/红)图片
├── pusher.py # 推送层:设备列表 / 推送图片(标准库 urllib ├── pusher.py # 推送层:设备列表 / 推送图片(标准库 urllib
├── run.sh # cron 包装脚本本机专用git 忽略) ├── run.sh # cron 包装脚本本机专用git 忽略)
├── fonts/ # Fusion Pixel 点阵字体OFL 协议) ├── fonts/ # Fusion Pixel 点阵字体OFL 协议)
@@ -87,10 +90,11 @@ QWEATHER_HOST=... QWEATHER_KEY=... WEATHER_LOCATION=经度,纬度 WEATHER_TTL
## 设计说明 ## 设计说明
- 设备为 **1bit 无真灰阶**,灰阶只能抖动成网点(难看),故全程纯黑白,靠构图、字号、留白建立层级。 - 设备为**三色(黑/白/红)无真灰阶**,灰阶只能抖动成网点(难看),故底色只用黑白 + 红作稀缺强调,靠构图、字号、留白建立层级。
- **红只承载语义**(方向·警示·强调),占墨保持很小(实测红≈全图 0.6%、占墨迹 <10%整图仅三种纯色 `(0,0,0)/(255,255,255)/(255,0,0)`无灰不抖动
- 字体用 **Fusion Pixel 12px 点阵字体**按整数倍尺寸(12/24/48)渲染关闭抗锯齿 像素锐利 - 字体用 **Fusion Pixel 12px 点阵字体**按整数倍尺寸(12/24/48)渲染关闭抗锯齿 像素锐利
重点数据用伪粗体」(偏移叠绘)加粗 重点数据用伪粗体」(偏移叠绘)加粗
- 天气图标为**全矢量手绘**(无图片资源):按和风 icon 代码归并为 8 类(晴/晴夜/多云/阴/雷/雨/雪/雾),用基本图元按 1bit 描边画出。 - 天气图标为**全矢量手绘**无图片资源按和风 icon 代码归并为 8 /晴夜/多云/////用基本图元描边画出太阳与闪电着红
- 趋势折线各指标按自身极值独立归一化,故只表达**趋势形状**,不可横向比绝对值 - 趋势折线各指标按自身极值独立归一化故只表达**趋势形状**不可横向比绝对值主指标(流水)着红领读末点标红点
- 推送用 `dither=false`(硬阈值),纯黑白图最锐利。 - 推送用 `dither=false`硬阈值三色纯色图最锐利
``` ```

View File

@@ -1,20 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
渲染层 —— 将数据绘制成 400x300 纯黑白墨水屏仪表盘。 渲染层 —— 将数据绘制成 400x300 三色(黑/白/红)墨水屏仪表盘。
设计要点: 设计要点:
- 纯黑白(设备无真灰阶灰阶只能抖动成网点,弃用);用构图 + 字号 + 留白建立层级 - 三色 BWR无真灰阶灰阶只能抖动成网点,弃用),底色黑白;红作为稀缺强调色点缀
红只承载语义——方向(涨)、警示、寻路锚点——占墨极小,靠构图 + 字号 + 留白建立层级。
- Fusion Pixel 点阵字体,按整数倍尺寸(12/24/48)渲染fontmode='1' 关闭抗锯齿 → 像素锐利。 - Fusion Pixel 点阵字体,按整数倍尺寸(12/24/48)渲染fontmode='1' 关闭抗锯齿 → 像素锐利。
- 重点数据用「伪粗体」(偏移叠绘)加粗,仍对齐像素网格。 - 重点数据用「伪粗体」(偏移叠绘)加粗,仍对齐像素网格。
布局:上=大时钟+日期 / 天气;中=橘喵今日经营三列;下=近30天三指标趋势折线图。 布局:上=大时钟+日期 / 天气;中=橘喵今日经营三列;下=近30天三指标趋势折线图。
红点缀清单:时钟冒号 / 定位图标 / 天气图标的太阳与闪电 / 两处标题方块■ / 环比同比“涨”三角 / 趋势图主指标(单量)折线。
""" """
import math import math
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import config import config
BLACK, WHITE = 0, 255 # 三色墨水屏(BWR)调色板:黑 / 白 / 纯正红。
# RED 用 (255,0,0) 作为设备红通道的明确信号——硬阈值量化下会干净地落到红色,不会被误判为黑。
# 设计准则:红是稀缺强调色,仅用于「方向/状态、警示、寻路锚点」,占墨比例应很小(≈全图 <10%),其余一律黑白。
BLACK, WHITE, RED = (0, 0, 0), (255, 255, 255), (255, 0, 0)
class DashboardRenderer: class DashboardRenderer:
@@ -70,10 +75,13 @@ class DashboardRenderer:
trw, tgap = (8, 4) if direction else (0, 0) trw, tgap = (8, 4) if direction else (0, 0)
x = cx - (lw + gap + trw + tgap + nw) / 2 x = cx - (lw + gap + trw + tgap + nw) / 2
self.text(x, y, label, self.F12, fill=fg); x += lw + gap self.text(x, y, label, self.F12, fill=fg); x += lw + gap
# 方向语义配色(红涨):涨 → 箭头与百分比数值一起红;跌/无 → 黑(标签始终黑)
up = direction == "up"
val_color = RED if up else fg
if direction: if direction:
# 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高 # 三角中心对齐 F12 数字墨迹竖直中心(y+8.5),否则箭头偏高
self.tri(x + trw / 2, y + 8.5, 7, up=(direction == "up"), fill=fg); x += trw + tgap self.tri(x + trw / 2, y + 8.5, 7, up=up, fill=val_color); x += trw + tgap
self.text(x, y, val, self.F12, fill=fg) self.text(x, y, val, self.F12, fill=val_color)
def _title_bar(self, y, title, upd): def _title_bar(self, y, title, upd):
"""标题栏:■ 标题(左) + 上次更新 HH:mm""" """标题栏:■ 标题(左) + 上次更新 HH:mm"""
@@ -139,12 +147,14 @@ class DashboardRenderer:
self.d.arc([x + S(12), cy - S(10), x + S(36), cy + S(14)], 270, 90, 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) 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): def _weather_icon(self, x, y, category, s=1.0, fg=BLACK, bg=WHITE, accent=None):
"""按天气类别画图标s 为缩放系数,原生约 36x38左上角 x,y""" """按天气类别画图标s 为缩放系数,原生约 36x38左上角 x,y
accent 用于「有能量」的元素——太阳、闪电——着红;云/雨/雪/雾/月保持 fg(黑)。"""
S = lambda v: v * s S = lambda v: v * s
accent = accent or fg
if category == "sunny": if category == "sunny":
self._sun(x + S(18), y + S(19), S(11), s, fg) self._sun(x + S(18), y + S(19), S(11), s, accent)
return return
if category == "clear_night": # 月牙:实心圆挖去偏上右的背景圆 if category == "clear_night": # 月牙:实心圆挖去偏上右的背景圆
@@ -165,7 +175,7 @@ class DashboardRenderer:
return return
if category != "rain" and category != "snow" and category != "thunder": # 多云及未识别:太阳 + 云 if category != "rain" and category != "snow" and category != "thunder": # 多云及未识别:太阳 + 云
self._sun(x + S(10), y + S(9), S(6), s, fg) self._sun(x + S(10), y + S(9), S(6), s, accent) # 探出的太阳着红
self._cloud(x, y + S(24), s, fg, bg) self._cloud(x, y + S(24), s, fg, bg)
return return
@@ -186,7 +196,7 @@ class DashboardRenderer:
bx = x + S(18) 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)), 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(6), cb + S(8)), (bx - S(4), cb + S(18)), (bx, cb + S(9)),
(bx - S(4), cb + S(9))], fill=fg) (bx - S(4), cb + S(9))], fill=accent) # 闪电着红作警示
# 线型样式:(实线段长, 间隙长);实线用 dash=0 表示连续 # 线型样式:(实线段长, 间隙长);实线用 dash=0 表示连续
_STYLES = {"solid": (0, 0), "dashed": (7, 4), "dotted": (1, 2)} _STYLES = {"solid": (0, 0), "dashed": (7, 4), "dotted": (1, 2)}
@@ -241,9 +251,10 @@ class DashboardRenderer:
SAMP, G1, GAP = 16, 8, 10 # 样例线长 / 线与名间隙 / 图例项间隙 SAMP, G1, GAP = 16, 8, 10 # 样例线长 / 线与名间隙 / 图例项间隙
x = self.W - 10 x = self.W - 10
for s in reversed(series): for s in reversed(series):
self.rtext(x, leg_y, s["k"], self.F12) # 指标名右端对齐 x 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 x -= self._tw(s["k"], self.F12) + G1
self._style_sample(x - SAMP, leg_y + 7, s["style"]) self._style_sample(x - SAMP, leg_y + 7, s["style"], fill=c)
x -= SAMP + GAP x -= SAMP + GAP
# ---- 绘图区 ---- # ---- 绘图区 ----
px0, px1 = 14, self.W - 10 px0, px1 = 14, self.W - 10
@@ -252,6 +263,7 @@ class DashboardRenderer:
n = len(dates) n = len(dates)
xstep = (px1 - px0) / (n - 1) if n > 1 else 0 xstep = (px1 - px0) / (n - 1) if n > 1 else 0
for s in series: for s in series:
color = RED if s.get("k") == "流水" else BLACK # 主指标(流水)为主角,着红领读
data = s["data"] data = s["data"]
lo, hi = min(data), max(data) lo, hi = min(data), max(data)
span = (hi - lo) or 1 span = (hi - lo) or 1
@@ -261,7 +273,10 @@ class DashboardRenderer:
x = px0 + xstep * i x = px0 + xstep * i
yv = py1 - pad - (v - lo) / span * (py1 - py0 - 2 * pad) yv = py1 - pad - (v - lo) / span * (py1 - py0 - 2 * pad)
pts.append((x, yv)) pts.append((x, yv))
self._styled_polyline(pts, s["style"]) 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 轴标签:仅首 / 中 / 末三个日期 ---- # ---- X 轴标签:仅首 / 中 / 末三个日期 ----
xl_y = py1 + 3 xl_y = py1 + 3
self.text(px0, xl_y, dates[0], self.F12) self.text(px0, xl_y, dates[0], self.F12)
@@ -270,10 +285,10 @@ class DashboardRenderer:
# ---------- 入口 ---------- # ---------- 入口 ----------
def render(self, data): def render(self, data):
"""根据 data 渲染并返回 PIL.Image'L' 模式,纯 0/255)。""" """根据 data 渲染并返回 PIL.Image'RGB' 模式,仅黑/白/红三色)。"""
self.img = Image.new("L", (self.W, self.H), WHITE) self.img = Image.new("RGB", (self.W, self.H), WHITE)
self.d = ImageDraw.Draw(self.img) self.d = ImageDraw.Draw(self.img)
self.d.fontmode = "1" # 关闭抗锯齿,点阵锐利 self.d.fontmode = "1" # 关闭抗锯齿,点阵锐利RGB 上仍生效,纯色描边不发糊)
dt, wt, mao = data["date"], data["weather"], data["mao"] dt, wt, mao = data["date"], data["weather"], data["mao"]
@@ -290,14 +305,14 @@ class DashboardRenderer:
# 定位图标:置于 loc 文字左侧,留间距并与文字竖直居中 # 定位图标:置于 loc 文字左侧,留间距并与文字竖直居中
PIN_W, PIN_GAP = 8, 6 PIN_W, PIN_GAP = 8, 6
loc_pin_x = 390 - self._tw(loc, self.F12) - PIN_GAP - PIN_W loc_pin_x = 390 - self._tw(loc, self.F12) - PIN_GAP - PIN_W
self._loc_pin(loc_pin_x, 10, PIN_W) self._loc_pin(loc_pin_x, 10, PIN_W, fg=RED) # 定位图标:红水滴,地图别针的通用语汇
s = 1.5 s = 1.5
iw, ih = 36 * s, 38 * s # 图标原生约 36x38 iw, ih = 36 * s, 38 * s # 图标原生约 36x38
text_left = min(loc_pin_x, text_left = min(loc_pin_x,
390 - self._tw(temp, self.F24) - 1, 390 - self._tw(temp, self.F24) - 1,
390 - self._tw(cr, self.F12)) 390 - self._tw(cr, self.F12))
icon_y = (DIV_Y - ih) / 2 # 顶部(0)与分割线正中:上下留白相等 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._weather_icon(text_left - 12 - iw, icon_y, self._weather_category(wt.get("icon")), s=s, accent=RED)
self.hdash(8, 392, DIV_Y) self.hdash(8, 392, DIV_Y)