Every non-deck, non-web scene now holds ten templates: cards (spec themes as living samples), infographics, tutorials, comparisons and carousels (nine spec themes with per-set master-board rules), plus two web landings whose 'generate from this' pins their measured style guides — a basis the document swap used to drop before the turn ever saw it. All fifty-eight ship generator-reproduced (sha256- matched), audit-clean, with baked preview cards, and the scene tab row pins Slides second-from-last and Web last. Two prompt-center entries carry the web pair with re-rendered previews and honest render provenance. Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
345 lines
15 KiB
Python
345 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""data-report-infographic.op — 数据结论信息长图(1080×N 竖版)
|
||
|
||
「信息图」这一档和卡片的区别不是画幅,是**阅读方式**:卡片是一眼看完,
|
||
长图是从上往下滑着读完。所以这张图的结构是一条阅读动线 ——
|
||
|
||
深色页头(结论先行)→ 三个大数 → 横向对比条 → 构成占比 → 三条结论 → 出处
|
||
|
||
—— 而不是卡片那种「标题 + 要点 + 署名」的静态版式。
|
||
|
||
图表纪律(dataviz)在这张图里的具体落法:
|
||
- **一个强调色,只给要说的那一条。** 对比条里只有第一名走青绿渐变,其
|
||
余四条统一走中性轨道色。五条都上色等于没上色。
|
||
- **不画图例,直接标在图上。** 数值贴在条的右端,占比标在色块下方,读
|
||
者的视线不需要在图和图例之间来回跳。
|
||
- **不画坐标轴和网格线。** 五条带标签的横条本来就自带比较基准,轴线只
|
||
是噪声。
|
||
- **数字用等宽感的 Inter,文案用 Noto Sans SC。** 全图两个字族封顶。
|
||
|
||
硬契约:
|
||
- 内容距边缘 ≥80px(这里 80)
|
||
- 配色全部走 color_vars,改主色只改 $c-accent / $c-accent-deep 两处
|
||
- 正文与背景对比度 ≥2.0(本配色最低一对见文件末尾注释)
|
||
- **CJK 行高比西文全线高 0.2**:页头大标题 1.2,区块标题 1.3,正文 1.7
|
||
- **CJK 负字距不超过 -0.02em**(汉字是满格设计,再负就笔画相撞);只有
|
||
西文数字沿用西文 display 的收紧
|
||
- 汉字走 Noto Sans SC,数字与拉丁走 Inter —— 等价于中文排印规范里
|
||
「西文在前、中文在后」的 fallback 链,只是在 .op 里按节点写死
|
||
- 顶层 frame 必须显式写 x/y,否则多帧会全部堆在原点
|
||
- 根高固定:ROOT_H 是量出来的(见文件末尾),改内容后要重量一次
|
||
|
||
### 负约束(本模板明令不做的事)
|
||
|
||
- 不画坐标轴、网格线、图例。带标签的横条自带比较基准,其余都是噪声。
|
||
- 不给第二条数据上强调色。五条里只有第一名走渐变,「都强调 = 没强调」。
|
||
- 不用蓝紫渐变、霓虹线条、复杂背景纹理(廉价 AI 科技风的三件套)。
|
||
- 不用伪 3D 图标、不用 emoji 当图标、不用装饰性插画。
|
||
- 不靠压字号硬塞:信息密度高时加区块,不缩字。
|
||
- 不写 AI 套话(「赋能 / 洞察闭环 / 一站式」),结论写成能直接排进日程的动作。
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
from oplib import (Ids, color_vars, frame, icon_font, linear, rect, solid,
|
||
text, write_doc)
|
||
|
||
ids = Ids()
|
||
|
||
VARS = color_vars({
|
||
"c-bg": "#F4F7F6",
|
||
"c-surface": "#FFFFFF",
|
||
# 页头与页脚的深底。近黑的墨绿而不是纯黑:它要和青绿强调色同一个色相家
|
||
# 族,长图从头到尾才像一份东西。
|
||
"c-band": "#0C2E2B",
|
||
"c-band-muted": "#9FBDB8",
|
||
"c-ink": "#0E2523",
|
||
"c-muted": "#5A736F",
|
||
"c-accent": "#0E9F8B",
|
||
# 小字压在浅底上时用的深一档。强调色本身在 #FFF 上只有 3.3:1,够大数字
|
||
# 不够 24px 的胶囊文字 —— 分两个变量比让 lint 过关更重要。
|
||
"c-accent-deep": "#096A5D",
|
||
"c-accent-soft": "#DCF2ED",
|
||
# 对比条的中性轨道 / 非强调条。它是「其余四条」的颜色,也是底槽的颜色,
|
||
# 同一个值让「没被强调的部分」在视觉上退成一个整体。
|
||
"c-track": "#DDE7E5",
|
||
"c-track-2": "#B7C9C6",
|
||
"c-border": "#E1EAE8",
|
||
})
|
||
|
||
CJK = "Noto Sans SC"
|
||
NUM = "Inter"
|
||
|
||
W = 1080
|
||
EDGE = 80
|
||
INNER = W - EDGE * 2
|
||
|
||
# 对比条的三段固定宽度:标签 / 轨道 / 数值。三个兄弟统一写死像素而不是混用
|
||
# fill_container —— 混用会让轨道长度随标签字数变化,五条就对不齐了。
|
||
BAR_LABEL_W = 210
|
||
BAR_VALUE_W = 116
|
||
BAR_GAP = 24
|
||
BAR_TRACK_W = INNER - BAR_LABEL_W - BAR_VALUE_W - BAR_GAP * 2
|
||
BAR_H = 46
|
||
|
||
# 量出来的根高。内容全是 fit_content,把根设成 fit_content 渲一次读 PNG 高
|
||
# 度,再把结果烤回来(同 saas-landing-orange 的 3240)。
|
||
ROOT_H = 2684
|
||
|
||
|
||
def band(name, *, fill, pad, gap, children, align="start"):
|
||
"""一个通栏区块。fill 决定它是不是一块有颜色的带 —— 结构容器不写 fill。"""
|
||
node = frame(ids, name, width="fill_container", height="fit_content",
|
||
layout="vertical", padding=pad, gap=gap, alignItems=align,
|
||
fill=fill)
|
||
node["children"] = children
|
||
return node
|
||
|
||
|
||
def col(name, children, *, gap=16, width="fill_container", align="start",
|
||
**props):
|
||
node = frame(ids, name, width=width, height="fit_content",
|
||
layout="vertical", gap=gap, alignItems=align, fill=[],
|
||
**props)
|
||
node["children"] = children
|
||
return node
|
||
|
||
|
||
def row(name, children, *, gap=24, align="center", width="fill_container",
|
||
**props):
|
||
node = frame(ids, name, width=width, height="fit_content",
|
||
layout="horizontal", gap=gap, alignItems=align, fill=[],
|
||
**props)
|
||
node["children"] = children
|
||
return node
|
||
|
||
|
||
def chip(label, *, bg, fg, size=24):
|
||
node = frame(ids, "胶囊", width="fit_content", height="fit_content",
|
||
layout="horizontal", padding=[10, 22], cornerRadius=999,
|
||
alignItems="center", justifyContent="center", fill=solid(bg))
|
||
node["children"] = [
|
||
text(ids, "胶囊文字", label, size, 600, fg, family=CJK,
|
||
width="fit_content", growth="auto", line_height=1.4),
|
||
]
|
||
return node
|
||
|
||
|
||
def section_head(title, note):
|
||
"""区块标题。一条短强调线 + 标题 + 一句说明,全图五处一模一样。"""
|
||
return col("区块头", [
|
||
rect(ids, "强调短线", width=72, height=8, cornerRadius=999,
|
||
fill=solid("$c-accent")),
|
||
text(ids, "区块标题", title, 46, 700, "$c-ink", family=CJK,
|
||
line_height=1.3),
|
||
text(ids, "区块说明", note, 27, 400, "$c-muted", family=CJK,
|
||
line_height=1.7),
|
||
], gap=16)
|
||
|
||
|
||
# ------------------------------------------------------------------ 01 页头
|
||
def header():
|
||
return band("01 页头", fill=solid("$c-band"), pad=[76, EDGE, 68, EDGE],
|
||
gap=26, children=[
|
||
chip("数据周报 · 2026 上半年", bg="$c-accent", fg="$c-band"),
|
||
text(ids, "主标题", "三个数字\n说清这半年", 76, 700, "$c-surface",
|
||
family=CJK, line_height=1.2, spacing=-1.4),
|
||
text(ids, "副标题", "全部口径与统计区间写在图片最下方,可直接转发。",
|
||
28, 400, "$c-band-muted", family=CJK, line_height=1.7),
|
||
])
|
||
|
||
|
||
# ------------------------------------------------------------------ 02 大数
|
||
KPIS = [
|
||
("2.4", "万", "月均活跃创作者", "+62%"),
|
||
("18", "分钟", "首次上手到出图", "−41%"),
|
||
("93", "%", "一次通过率", "+9pt"),
|
||
]
|
||
|
||
|
||
def kpi_card(value, unit, label, delta):
|
||
"""大数字卡。数值走强调色、单位退成中性 —— 一张卡里只有一个视觉重心。"""
|
||
value_row = row("数值", [
|
||
text(ids, "数值", value, 78, 700, "$c-accent-deep", family=NUM,
|
||
width="fit_content", growth="auto", line_height=1.0, spacing=-3),
|
||
text(ids, "单位", unit, 28, 600, "$c-muted", family=CJK,
|
||
width="fit_content", growth="auto", line_height=1.0),
|
||
], gap=8, align="end", width="fit_content")
|
||
|
||
card = col("大数卡", [
|
||
value_row,
|
||
text(ids, "指标名", label, 26, 600, "$c-ink", family=CJK,
|
||
line_height=1.4),
|
||
chip(delta, bg="$c-accent-soft", fg="$c-accent-deep", size=22),
|
||
], gap=16, padding=[32, 28], cornerRadius=20)
|
||
card["fill"] = solid("$c-surface")
|
||
card["stroke"] = {"thickness": 2, "fill": solid("$c-border")}
|
||
card["height"] = "fill_container"
|
||
return card
|
||
|
||
|
||
def kpis():
|
||
grid = row("大数网格", [kpi_card(*entry) for entry in KPIS], gap=20,
|
||
align="stretch")
|
||
return band("02 大数", fill=[], pad=[64, EDGE, 0, EDGE], gap=32, children=[
|
||
section_head("先看三个数",
|
||
"只挑能直接下结论的指标,其余都放在文末的口径说明里。"),
|
||
grid,
|
||
])
|
||
|
||
|
||
# ------------------------------------------------------------------ 03 对比
|
||
# (标签, 数值文案, 占满轨道的比例)。比例是画出来的长度,数值是写出来的事实,
|
||
# 两者必须自己对齐 —— 没有任何一层会替你检查它们是否一致。
|
||
BARS = [
|
||
("模板起稿", "4.6k", 1.00),
|
||
("空白起稿", "2.9k", 0.63),
|
||
("导入截图", "1.7k", 0.37),
|
||
("复制他人", "0.9k", 0.20),
|
||
("其他入口", "0.4k", 0.09),
|
||
]
|
||
|
||
|
||
def bar_row(label, value, ratio, highlight):
|
||
track = frame(ids, "轨道", width=BAR_TRACK_W, height=BAR_H,
|
||
layout="horizontal", alignItems="center", gap=0,
|
||
cornerRadius=BAR_H // 2, clipContent=True,
|
||
fill=solid("$c-track"))
|
||
track["children"] = [
|
||
rect(ids, "条", width=max(round(BAR_TRACK_W * ratio), BAR_H),
|
||
height=BAR_H, cornerRadius=BAR_H // 2,
|
||
# 90° = 自左向右:深端坐在起点,条尾收到主强调色。只有第一名用
|
||
# 渐变,其余走中性 —— 这就是「一个强调色只给要说的那一条」。
|
||
fill=(linear(90, [(0.0, "$c-accent-deep"), (1.0, "$c-accent")])
|
||
if highlight else solid("$c-track-2"))),
|
||
]
|
||
return row("对比行", [
|
||
text(ids, "条标签", label, 27, 500, "$c-ink", family=CJK,
|
||
width=BAR_LABEL_W, line_height=1.4),
|
||
track,
|
||
text(ids, "条数值", value, 30, 700,
|
||
"$c-accent-deep" if highlight else "$c-muted", family=NUM,
|
||
width=BAR_VALUE_W, align="right", line_height=1.4),
|
||
], gap=BAR_GAP)
|
||
|
||
|
||
def comparison():
|
||
rows = [bar_row(label, value, ratio, index == 0)
|
||
for index, (label, value, ratio) in enumerate(BARS)]
|
||
return band("03 对比", fill=[], pad=[72, EDGE, 0, EDGE], gap=32, children=[
|
||
section_head("新图是从哪儿开始的",
|
||
"近 30 天新建文档的起稿方式,按数量从多到少。"),
|
||
col("对比列表", rows, gap=22),
|
||
])
|
||
|
||
|
||
# ------------------------------------------------------------------ 04 构成
|
||
SPLIT = [
|
||
("社交卡片", "52%", 0.52, "$c-accent-deep"),
|
||
("演示文稿", "31%", 0.31, "$c-accent"),
|
||
("其他", "17%", 0.17, "$c-track-2"),
|
||
]
|
||
|
||
|
||
def composition():
|
||
stacked = frame(ids, "占比条", width="fill_container", height=56,
|
||
layout="horizontal", gap=0, cornerRadius=28,
|
||
clipContent=True, fill=solid("$c-track"))
|
||
# 最后一段吃掉四舍五入的余数:三段各自 round 之后总和会比 INNER 少 1-2
|
||
# px,底槽就从右端露出一道细缝,看起来像「占比没加满 100%」。
|
||
widths = [round(INNER * ratio) for _, _, ratio, _ in SPLIT]
|
||
widths[-1] = INNER - sum(widths[:-1])
|
||
stacked["children"] = [
|
||
rect(ids, f"占比段 · {name}", width=width, height=56, fill=solid(color))
|
||
for (name, _, _, color), width in zip(SPLIT, widths)
|
||
]
|
||
|
||
legend_items = []
|
||
for name, pct, _, color in SPLIT:
|
||
legend_items.append(row("图例项", [
|
||
rect(ids, "色点", width=18, height=18, cornerRadius=9,
|
||
fill=solid(color)),
|
||
text(ids, "图例名", name, 26, 500, "$c-ink", family=CJK,
|
||
width="fit_content", growth="auto", line_height=1.4),
|
||
text(ids, "图例值", pct, 26, 700, "$c-muted", family=NUM,
|
||
width="fit_content", growth="auto", line_height=1.4),
|
||
], gap=12, width="fit_content"))
|
||
|
||
return band("04 构成", fill=[], pad=[72, EDGE, 0, EDGE], gap=32, children=[
|
||
section_head("这些图最后变成了什么",
|
||
"按导出成品分类。一份文档只计一次,取最后一次导出。"),
|
||
col("占比区", [
|
||
stacked,
|
||
row("图例", legend_items, gap=40, width="fill_container"),
|
||
], gap=24),
|
||
])
|
||
|
||
|
||
# ------------------------------------------------------------------ 05 结论
|
||
TAKEAWAYS = [
|
||
"模板起稿是空白起稿的 1.6 倍,入口比数量更值得投入。",
|
||
"首次出图时间压到 18 分钟之后,一次通过率才开始往上走。",
|
||
"社交卡片占了一半以上的成品,但模板供给还集中在演示文稿。",
|
||
]
|
||
|
||
|
||
def takeaways():
|
||
items = []
|
||
for line in TAKEAWAYS:
|
||
items.append(row("结论项", [
|
||
icon_font(ids, "对勾", "check-circle", 30, "$c-accent-deep"),
|
||
text(ids, "结论文字", line, 28, 500, "$c-ink", family=CJK,
|
||
line_height=1.7),
|
||
], gap=18, align="start"))
|
||
|
||
panel = col("结论面板", items, gap=22, padding=[40, 36], cornerRadius=24)
|
||
panel["fill"] = solid("$c-accent-soft")
|
||
|
||
return band("05 结论", fill=[], pad=[72, EDGE, 76, EDGE], gap=32, children=[
|
||
section_head("那么该做什么",
|
||
"三条能直接排进下个季度的动作,其余留给评论区。"),
|
||
panel,
|
||
])
|
||
|
||
|
||
# ------------------------------------------------------------------ 06 页脚
|
||
def footer():
|
||
return band("06 页脚", fill=solid("$c-band"), pad=[44, EDGE], gap=12,
|
||
children=[
|
||
text(ids, "出处", "数据口径:2026-01-01 至 2026-06-30,去重后按文档计。",
|
||
24, 400, "$c-band-muted", family=CJK, line_height=1.6),
|
||
row("署名行", [
|
||
text(ids, "账号名", "@ 你的账号名", 26, 600, "$c-surface",
|
||
family=CJK, width="fit_content", growth="auto",
|
||
line_height=1.4),
|
||
text(ids, "更新说明", "每月一张数据图", 24, 400, "$c-band-muted",
|
||
family=CJK, width="fit_content", growth="auto",
|
||
line_height=1.4),
|
||
], gap=16, width="fill_container"),
|
||
])
|
||
|
||
|
||
def build():
|
||
page = frame(ids, "数据结论长图", width=W, height=ROOT_H, layout="vertical",
|
||
gap=0, fill=solid("$c-bg"), clipContent=True)
|
||
page["children"] = [header(), kpis(), comparison(), composition(),
|
||
takeaways(), footer()]
|
||
page["x"], page["y"] = 0, 0
|
||
return [page]
|
||
|
||
|
||
# 对比度(WCAG 相对亮度比,op-design-lint 的门槛是 2.0;数值实测):
|
||
# c-surface on c-band 14.57 c-band-muted on c-band 7.25
|
||
# c-ink on c-bg 14.90 c-muted on c-bg 4.73
|
||
# c-ink on c-surface 16.06 c-muted on c-surface 5.10
|
||
# c-accent-deep on c-surface 6.50 c-band on c-accent 4.41
|
||
# c-accent-deep on c-accent-soft 5.56 c-ink on c-accent-soft 13.74
|
||
# 最低一对 4.73 仍高于 WCAG AA 的 4.5。强调色本身 (#0E9F8B) 只出现在 78px
|
||
# 的大数、色块和 5px 的短线上,从不承载小字 —— 它在 #FFF 上只有 3.31。
|
||
|
||
if __name__ == "__main__":
|
||
write_doc(sys.argv[1], VARS, build(), "数据结论信息长图")
|