Skip to content

Repository files navigation

AAClock

一个可交互的反走样时钟。同一套几何、两条采样路径、一个能看设备像素的放大镜,用来把"反走样到底是什么"从口诀变成可量的事实;外观全部由 Hana 的主题 token 驱动,与宿主同一套配色。

单页 WebGL2,无框架、无运行时依赖,Vite + TypeScript。

用法

git submodule update --init --depth 1   主题 CSS 与显示名取自 submodule(§5.1)
pnpm install
pnpm dev                       开发(热更新)
pnpm build                     dist/:多文件,不压缩
pnpm build:minify              dist/:多文件,压缩
pnpm build:standalone          dist/index.html:单文件,不压缩
pnpm build:standalone:minify   dist/index.html:单文件,压缩

两种构建都落在 dist,每次都清空重建。自包含版外部引用为 0,可以直接双击打开。

下面讲的是设计本身:先意图与关键决策,再形态;接口(DOM 契约、模块划分)在 §8 与 §9。

1. 意图

反走样在多数人脑子里是一句口诀:边缘加点灰、别让它那么硬。口诀不难记,难的是说清"灰是多少"、"为什么是这个数"、"多采几次会怎样"。这个钟面就是一具可以量的标本。

三条对照:

  • 同一套几何,两条采样路径。 1 点/像素与 n×n 超采样走的是同一条硬阈值路径,解析覆盖是另一条。切换时几何一个像素都不动,只有覆盖率算法变。
  • 一个能看设备像素的放大镜。 真实设备像素被放大、网格画在源像素边界上、中心那一格的十六进制值实时读出。指针边缘"到底是不是灰的"不再靠眼看。
  • 时间轴上的对照。 连续秒针与跳秒,是空间锯齿在时间上的同一件事。

观众有两个:写它的人(要能验证、能改),和演示机前的人(分辨率可能不高,配色必须与宿主一致、一眼看得懂)。

2. 关键决策

复盘的主干。每条写:选了什么、为什么、以及否掉了什么。

用距离场表示几何,而不是路径描边。 三条采样路径都需要"到边缘的距离"这一个量:解析覆盖把它除以像素长度,超采样只需要它的符号。把几何写成 SDF,采样方式就退化成一个可替换的部件。代价是每帧要为每个像素求若干次距离(见 §7)。

覆盖率即 alpha,禁止用 if (d < 0) 切色。 所有图层都写成 mix(dst, src, coverage)。这条是整个项目的地基——一旦某处用分支切色,那片区域的锯齿就只与像素位置有关,在放大镜里一目了然。它也是"覆盖场"视图能成立的前提:场本身光滑,锯齿来自硬阈值化。

1×1 与"1 点/像素"合并成一条路径。 子样本落在像素中心、硬覆盖不做平均,两者本来就是同一件事。曾经把它们做成两种模式,结果是同一份代码走两条分支、读数还要解释"这两个有什么不同"。现在只有一个采样轴:0 = 解析覆盖,1–9 = 每轴子样本数。

混合空间可选 sRGB 直混或线性光。 反走样不只是"采样几次",还取决于在哪个空间里平均:在 sRGB 编码值上平均,边缘会偏暗。把它做成开关,差异当场可见。

主题是宿主的数据,不是本项目的数据。 主题 CSS 与主题显示名都从上游仓库现取(submodule,见 §5.1),页面只消费 --bg / --accent 这类变量,不自造颜色。曾经为了"色值跟宿主一致"抄过一张 TS 色表,那是二手翻译:既要保持同步,又容易抄错。并且不接宿主协议——postMessage 握手是插件 UI 的礼节,独立页永远收不到广播,写了就是死代码;明暗跟随交给 prefers-color-scheme 的继承。

表盘严格居中,放大镜挂到盘外。 放大镜比表盘更挡视线,而它只是工具。放不进右侧时才收回盘内叠放。顶部同样做成浮层:表盘四周留有一圈投影余地,顶栏压在那圈余地上不盖盘面,却把一整行高度还给了表盘。

时间只受显式的偏移与校准影响。 暂停不追平真实时间(追平是一次隐式校准,校准该显式按按钮);拖动拨针按手的位置决定拨哪根(比记修饰键直接),拖动期间冻结走时(否则针一边跑手一边追),松手从拨好的时刻接着走、指针不跳。

每个控件都有一枚键,且键序与面板分组一致。 提示行与面板同一行,读起来就是面板的地图:1–9 采样、B 混合空间、T 秒针运动、F V G Space R 视图、H 收起。时区与主题是选择器,不配键。

产物两条:多文件与自包含单文件,都不压缩。 压缩与"是否单文件"本是两件事,一度被绑成一个开关。现在 standalone 只意味着"把 JS 与 CSS 内联进 index.html",两边都不压缩——产物是拿来读的。

3. 渲染

3.1 坐标

片元着色器里:

vec2 p = (gl_FragCoord.xy - 0.5 * uRes) / (0.5 * min(uRes.x, uRes.y));

p 是以钟面中心为原点、以短边为标准的归一化坐标,半径 1.0 即短边一半。 uPx = 一个设备像素在 p 空间里的长度 = 2.0 / min(uRes.x, uRes.y)。分辨率始终为正方形(见 §7)。

角度约定:a = 0 指向 12 点,顺时针为正,方向向量 vec2(sin(a), cos(a))。三针共用这一套,所以拨针的命中测试(§4.2)与着色器能用同一个角度。

3.2 SDF 图元

  • sdCircle(p, r)
  • sdBox(p, b)
  • sdRoundCone(p, a, b, r1, r2) — 从 a 到 b、两端半径分别为 r1/r2 的锥形胶囊(手针用)。用 iq 的稳健三分支写法,r1 == r2 时也要正确退化为圆柱。
  • sdTick(p, count, rOut, rIn, wAng) — 刻度环。转极坐标,用 mod 求最近刻度角,把角差乘半径化为弧长,在 (弧长, 半径) 平面里做 sdBox。切向半宽取 wAng * r(等角宽度,指向圆心)。length(p) 接近 0 时 atan 会溢出,需要兜底。

3.3 覆盖函数

距离转覆盖率,这是两条采样路径唯一的分歧点:

float covAA(float d)   { return clamp(0.5 - d / uPxDial, 0.0, 1.0); }  // 解析覆盖:1 像素宽的线性过渡
float covHard(float d) { return step(d, 0.0); }                        // 单点采样:硬阈值

所有图层都用 mix(dst, src, coverage) 合成。

3.4 钟面图层(画家算法,从下往上)

层 图元 颜色
投影 sdCircle(p - vec2(0, -0.045), R_FACE) uShadow,高斯式衰减 exp(-max(d,0)*14) * uShadowAlpha,画在面之前,只在外侧可见
表盘面 sdCircle(p, R_FACE) uCard → uBg 的径向渐变(rN = clamp(len/R_FACE,0,1),mix(uCard, uBg, 0.35 + 0.45*rN*rN))
收边环 abs(sdCircle(p, R_BEZEL)) - 0.0022 uEdge
分针位刻度 ×60 sdTick mix(uCard, uMuted, 0.6),覆盖率乘 (1 - cHour)
整点刻度 ×12 sdTick uInkSoft
时针 sdRoundCone(0, hd*L_HOUR, 0.0285, 0.0105) uInkSoft
分针 sdRoundCone(0, md*L_MIN, 0.0225, 0.0085) uInk
秒针晕影 到秒针线段的距离,exp(-d²*300) * uGlow uAccent,强度 0.16,画在秒针之下
秒针 sdRoundCone(-sd*T_SEC, sd*L_SEC, 0.0085, 0.0048) 与近针尖的细环 abs(sdCircle(p - sd*(L_SEC*0.88), 0.020)) - 0.0030 取并集 uAccent
轴心 环 abs(sdCircle(p, 0.052)) - 0.0035 → uCard;盘 sdCircle(p, 0.037) → uInk;点 sdCircle(p, 0.0125) → uAccent

刻度只有两档:整点 12 根最长、分针位 60 根最短。不存在独立的"五分位"档——整点标记本来就落在五分位上(12 根、每 30° 一根),第三档会被整点档完全遮住。

秒针晕影算的是到线段的距离而不是到无限直线的距离:用直线时晕影会顺着针的方向铺满整个画布、越出表盘。

几何常量存两份,人工同步。 渲染那份是 dial.frag 里的 const,命中测试(§4.2)读 DIAL.ts。必须保持一致:命中测试要用与渲染同一份针长,各写一套迟早分岔。 FIT = DIAL.fit = 0.84 表示整盘在画布内再收一圈。收到这个值是因为:投影沿 y 向下偏 0.045 且半径等于 R_FACE,不收的话它会在方框下沿被切出一条直边,看起来像画错了。

几何常量(dial.frag 的 const 与 DIAL.ts 各一份):

R_FACE    0.862      R_BEZEL   0.836      R_TICKOUT 0.786
刻度里端半径(越小线越长):R_TICKMINUTE 0.742   R_TICKHOUR 0.700
刻度角宽(越长越粗):W_TICKMINUTE 0.0026   W_TICKHOUR 0.0052
L_HOUR    0.404      L_MIN     0.618      L_SEC     0.706      T_SEC 0.148
FIT       0.84

盘面空间的像素长度是 uPxDial = uPx / FIT:覆盖函数与覆盖场的等距线都用它,而超采样的子样本偏移仍用画布空间的 uPx——两者不能混。

秒针晕影强度 uGlow 在"跳秒"模式下为 0(那一瞬间没有扫过动作),连续模式下为 1。

3.5 采样与收敛

uMode = 0(超采样)    n = uSamples(1–9),像素内取 n×n 个均匀子样本,
                       每个用 hard 覆盖求色再平均;n = 1 即 1 点/像素
uMode = 1(解析覆盖)  col = shade(p, aa)

超采样与解析覆盖收敛到同一结果,但误差不随样本数单调下降:像素内子样本栅格相位固定,误差在 ±1/(2n) 的包络内振荡。 实测(720px 画布扫过 12 点方向长刻度的竖直边缘,边缘距像素中心 0.12px,解析覆盖 0.380):

n 2 3 4 5 6
覆盖率 0.496 0.331 0.496 0.397 0.331
误差 0.116 0.049 0.116 0.017 0.049

看收敛要看包络是否按 1/(2n) 收缩,不看单次差异。这也是把上限放到 9 而不是 6 的理由:包络更窄,收敛的样子更容易看出来。

混合空间 uLinear:

  • uLinear = 0(sRGB 直混):图层合成与样本平均都在 sRGB 编码值上做。
  • uLinear = 1(线性光):合成为 pow(mix(pow(dst,2.2), pow(src,2.2), cov), 1/2.2);超采样改为先把每个样本转线性、平均后再转回。

3.6 覆盖场视图(uField = 1)

把距离场本身画出来,替代正常着色:取关键图元(刻度、三根指针、轴心)的并集距离 d, 以 t = clamp(d/uPxDial, -6, 6) 在 uAccent(内侧)与 uBg(外侧)之间过渡, 并叠加每 1 像素一条的等距线(abs(fract(d/uPxDial) - 0.5) 靠近 0.5 处混入 uCoral)。 这张图就是"SDF 是光滑的、边缘的锯齿来自把光滑场按像素硬阈值化"的现场证据。

3.7 uniform 清单

vec2  uRes          设备像素尺寸
float uPx           一个像素在画布空间里的长度
float uPxDial       一个像素在盘面空间里的长度(= uPx / FIT)
vec3  uAng          时/分/秒角度(弧度)
int   uMode         0 = 超采样,1 = 解析覆盖
int   uSamples      每轴子样本数(1–9)
int   uField        覆盖场开关
int   uLinear       混合空间开关
float uGlow         秒针晕影强度
float uShadowAlpha  投影强度(`--shadow` 的 alpha)
vec3  uBg, uCard, uEdge, uInk, uInkSoft, uMuted, uAccent, uCoral, uShadow

顶点着色器用 gl_VertexID 生成全屏三角形,配一个空 VAO——没有顶点数据要传,几何全在片元里算。

4. 时间与交互

4.1 时间模型

墙上时刻 = Date.now() + offset + 时区偏移;连续模式秒值带毫秒小数,跳秒模式取整到秒。

  • 偏移只由两件事改:拖动拨针(累加),与校准按钮(清零)。
  • 时区是纯整点偏移(UTC-11 … UTC+11),初值由浏览器时区取整得到,固定不跟夏令时(代价见 §11)。
  • 暂停冻结显示;暂停期间拨针量保留,恢复后从冻结时刻接着走,不追平真实时间。
  • 读数 12 小时制 hh:mm:ss.mmm,与 12 小时表盘一致;不带 AM/PM——表盘上本来就没有这个记号。

4.2 拨针

指针在 dial 内按下,按抓取位置决定拨哪根针:三针各自算指针角度差,越出该针长度重罚,重叠时压在上面的优先(秒 > 分 > 时),取分数最低者。拖动角度差按该针的整圈周期换成时间(时针 12 小时 / 分针 1 小时 / 秒针 1 分钟),累加进偏移。不用修饰键。

拖动期间自动冻结走时(只针对本次拖动,松手从拨好的时刻接着走;你手动按下的暂停不动),否则针一边跑手一边追,对不准。

4.3 放大镜与滚轮

放大镜读真实设备像素,最近邻放大,网格画在源像素边界上。滚轮在 dial 上调源窗口边长(设备像素),步长 1 像素,从 1 倍(窗口 = 放大镜画布)到 4 像素。用窗口像素数而不是倍数来表达,是因为"一个放大镜像素对应几个源像素"本来就是这件事的实质。

指针离开后停在最后一次位置(用法就是"指住一个点、再去读放大画面");放大镜开着时表盘上隐藏光标,否则指针本身会挡住取样框。

4.4 键盘

顺序与控制台的分组一致:1–9 设每轴样本数,0 切解析覆盖,B sRGB/线性光,T 跳秒/连续,F 覆盖场,V 放大镜,G 像素网格,Space 暂停/继续,R 校准,H 收起/展开参数。时区与主题只用下拉,不配键;焦点在输入控件上时不拦截。

默认视图落在 1 点/像素(不抗锯齿),像素网格与放大镜默认关着——先把钟面本身给出来,工具按需再开。

5. 主题

5.1 上游引用

主题定义不是本项目的数据,因此不入库:上游仓库以 submodule 挂在 vendor/openhanako,pin 在 v0.450.0(1d3ef308),浅克隆。

  • 主题 CSS 在 vendor/openhanako/desktop/src/themes/,原文一字不改。themes/index.ts 逐个文件显式 import 清单里的 11 个配色主题,不 glob 整个目录——上游那份目录里还有本页不消费的 new-warm-paper-fonts.css 与字体文件。
  • 主题显示名在 vendor/openhanako/desktop/src/locales/zh.json 的 settings.appearance。构建期由 upstreamThemeLabels 插件摘出清单里的那 11 条(§7.2),整个 locale 文件不进产物。
  • 清单(主题 id 即 [data-theme] 取值与上游文件名,以及它对应的 locale key)写在 themes/catalog.ts。上游改了文件名或 key,构建直接报错,不静默回落到 id。

压缩模式下会裁掉产物里页面不消费的主题变量与关键帧(§7.2)。

5.2 控制器

themes/controller.ts(ThemeController)职责:

  1. 解析主题 id,优先级从高到低:URL ?theme=<id>(只在初次解析时认一次)> localStorage['aaclock:theme'] > 系统明暗(matchMedia('(prefers-color-scheme: dark)') 命中取 midnight,否则 warm-paper,与宿主的默认搭配一致)。存储值 auto 表示跟随系统。无法识别的 id 忽略,继续往下回退。
  2. 应用:documentElement.dataset.theme = id(CSS 自动生效)+ documentElement.style.colorScheme = dark ? 'dark' : 'light'(原生控件配色)。暗色判定由底色亮度反推,省掉一张"哪些主题是暗色"的表。
  3. 读取:getComputedStyle(documentElement) 取变量,转成 GL palette 交给 renderer。只在此处、只在应用主题时读一次,不每帧读。
  4. themeSelect 首项为"自动",其余列出清单里的主题(themes/catalog.ts),显示名取上游 locale 的中文名(构建期摘取,§5.1);切换即写回 localStorage。
  5. 处于 auto 时监听 matchMedia 的 change 事件重新解析(保存 MediaQueryList 引用,不在回调里新建)。

页面消费的变量:--bg --bg-card --bg-glass --accent --accent-hover --accent-light --text --text-light --text-muted --border --shadow --overlay-subtle --overlay-light --overlay-medium --overlay-strong --green --coral --danger。

不自造颜色变量:不要 --glass / --accent-ink / --shadow-strong,分别改用 --bg-glass / --accent-light(选中态用 Hana 的"浅底深字"式:--accent-light 底 + --accent 字)/ --shadow。圆角写成 calc(<r> * var(--corner-radius-scale))——这个比例在 styles.css 里定义,支持 corner-shape: squircle 时放大,改半径时相关内缩量会自动跟上。

5.3 CSS 变量 → GL palette

GL uniform CSS 变量 备注
uBg --bg
uCard --bg-card
uInk --text
uInkSoft --text-light
uMuted --text-muted
uAccent --accent
uCoral --coral
uEdge --border 常为 rgba(),需与 --bg 合成成实色
uShadow --shadow 取 rgb 分量,并把它的 alpha 作为独立强度系数传给着色器;若把 alpha 合成掉,投影会直接消失(浅色主题 --shadow 的 alpha 只有 0.04~0.11)

6. 布局

版面自上而下:顶栏浮在表盘区之上(不占行);中部表盘 严格居中,放大镜挂在表盘右边缘之外;底部是"提示行 + 参数控制台",按内容收窄、整体居中。

  • 顶栏浮层:表盘四周本来留有一圈投影余地(FIT = 0.84),顶栏压在那圈余地上不会盖住盘面,却把一整行高度还给表盘。小窗口下先吃留白、再压阴影圈,约 420px 高的窗口才会碰到盘面。浮层加 pointer-events: none,不在表盘上方吃指针事件。
  • 放大镜在盘外:它比表盘更挡视线,而它只是工具。挂法是在表盘的 left: 100% 之外,绝对定位不参与布局,所以表盘不被它推偏;右侧放不下时加 loupe-overlay 收回盘内叠放。判定在 resize 里实算(表盘宽 + 面板宽 + 边距 vs 视口宽),不用写死的断点。
  • 底部块:提示与收起按钮同一行,左侧用等宽占位保证提示仍在整体居中;整块按内容收窄,于是那行的右端与面板右缘对齐。收起按钮属于面板,面板一收它随之退场;提示行是面板之外的东西,留在页面底部。
  • 收起让位:收起时表盘上限从 72vh 提到 86vh,并把尺寸对齐立刻重跑一次(不等 ResizeObserver)。浮标的横坐标在收起那一刻从收起按钮量下来,点同一个位置就能来回切。

7. 构建与性能

7.1 尺寸与填充率

  • dial 是正方形(aspect-ratio: 1),宽度交给可用空间:展开态 min(100%, 72vh)、收起态 min(100%, 86vh)。设备像素边长 S = clamp(round(cssSide * dpr), 256, 4096),画布 width = height = S。天花板 4096 与性能无关(真实屏幕碰不到,它约合 5700px 高的视口),只用来兜住 GL 缓冲尺寸上限那类硬失败。
  • 填充率是这个项目唯一的性能变量:S² × n² 个着色调用/帧。9×9 在 720px 画布上实测约 42 fps,同一画布下 4×4 是 140 fps、1×1 是 144 fps。帧率就写在顶栏,代价随时看得见。
  • statsText 每帧更新:W×H · n×n 超采样 · N 样本/像素 · xx fps(非超采样模式显示对应模式的简短描述)。帧率取最近 500 ms 窗口的算术平均。
  • 主循环用 requestAnimationFrame。放大镜只在开启时读像素;取样点默认落在表盘中心,指针进入表盘后跟随指针。
  • 覆盖层画布与放大镜画布的尺寸按 DPR 设置,绘制用 CSS 像素坐标。

7.2 构建

命令见文首。两种构建都落在 dist,每次都清空重建:产物永远是刚构建出来的那一份。模式之间的差别只有"是否内联",压缩与否两种模式一致(见 §2)。

压缩模式下四类东西各有各的压缩器,都只在 --mode minify 下生效:

  • JS / CSS 用 Vite 自己的默认项(minify: 'oxc'、cssMinify: 'lightningcss')。同一份代码上 oxc 比 terser 更小,构建也更快,所以不换。
  • HTML 交给 html-minifier-terser(collapseWhitespace + removeComments)。它不碰内联的 script / style——模块脚本是已经压过的 JS。
  • 主题 CSS 交给 purgecss 裁掉页面不消费的变量与关键帧。判据只认产物自己:CSS 内的 var() 引用,加上 JS 与 HTML 里出现过的变量名(JS 用 getComputedStyle 按名字读变量,那些名字不在 CSS 里)。选择器一并按内容裁。裁完内容变了,CSS 资产重发一份,文件名里的 hash 跟着内容走。
  • 主题显示名由 upstreamThemeLabels 在构建期从上游 locale 里摘出清单里的那几条(§5.1),以虚拟模块 virtual:aaclock-upstream-labels 交给 controller.ts。上游缺条目或缺 key 时构建失败,显示名不静默回落到 id。
  • GLSL 不写 ?raw:*.frag / *.vert 由 shaderSource 插件转给 Vite 自己的 raw 加载器,类型由 types/shader.d.ts 声明,导入处只剩文件名。压缩由 minifyRawShaders 在模块被压缩之前完成:GLSL 在 bundle 里只是一段字符串,JS 压缩器不碰字符串内容。按 C 族词法走 glsl-tokenizer,去注释与空白,其余记号原字面照抄;源码里本来就挨着的记号(<<、+= 这类会被切成两个)之间不插字符,隔着空白或注释的只在两个字面会粘成一个新记号时补一个空格,#version 独占一行。字面量一字不改,所以产物与源逐字等价。glsl-tokenizer 不带类型声明,上游也没有 @types 包,types/glsl-tokenizer.d.ts 只声明本项目用到的那一个入口。
  • 产物里的换行由 escapeNewlines 写成转义:JS 压缩器会把含换行的字符串写成模板字面量,换行也就真落进产物,文件里就多出真实换行。只动不含 $、反引号与反斜杠的模板字面量——这类字面量的原文就是字面内容,换成 JSON 字符串逐字等价;带替换的、带转义的都不碰。它排在 inlineStandalone 前面,单文件模式也一样。

自包含版把 JS 与 CSS 内联进 index.html,外部引用为 0,可以直接从 file:// 打开。内联走 Vite 自己的 generateBundle,不读磁盘、不引插件,也不欠 @types/node。

8. DOM 契约

index.html 里的 id 与 data-* 属性即接口。

id 用途
dial 表盘容器,指针事件与尺寸测量都在这里
glCanvas WebGL2 画布,绘制钟面
overlayCanvas 2D 画布,叠在 GL 之上,只画放大镜取样框
loupe / loupeCanvas 放大镜面板与其 2D 画布
loupeHex / loupeCoord 放大镜中心像素的色值、坐标与源窗口边长读数
clockText / statsText 走时读数、采样统计
modeSeg button[data-analytic] = 0/1:超采样 / 解析覆盖
samplesGroup / samples / samplesValue 超采样每轴样本数(1–9),解析覆盖下整组置 is-muted 且 disabled
gammaSeg button[data-linear] = 0/1,混合空间
tickSeg button[data-tick] = continuous / step,秒针运动
fieldBtn / loupeBtn / gridBtn / pauseBtn aria-pressed 开关:覆盖场、放大镜、像素网格、暂停
resetBtn 校准:把时间偏移清回真实时钟
console 底部参数控制台,可整块收起
collapseBtn / expandBtn 收起 / 展开参数面板
timezoneSelect 时区:整点偏移 UTC-11 … UTC+11
themeSelect 主题选择,首项为"自动"
fatal / fatalText 初始化失败时展示原因(WebGL2 不可用、着色器编译失败等)

样式上有一条纪律:新增样式追加到 styles.css 末尾,不覆盖既有选择器语义。

9. 模块划分

src/
  main.ts              装配:rAF 主循环、尺寸/DPR、读数、事件绑定
  styles.css           页面外观,只消费主题 CSS 提供的变量
  color.ts             颜色解析与混合(计算样式里的色值 → RGBA / 0-1 浮点)
  themes/              上游主题清单、主题 CSS 入口、主题控制器
  clock/
    dial.frag          GLSL:SDF 图元、覆盖函数、钟面图层
    fullscreen.vert    GLSL:全屏三角形
    DIAL.ts            几何常量(盘面空间,拨针命中测试用)
    renderer.ts        WebGL2 上下文、程序、uniform、绘制、读像素
    time-model.ts      时间模型:偏移、时区、暂停、秒针量化、指针角度
    loupe.ts           放大镜:读像素 → 放大 → 像素网格 → 取样框

切分的依据是谁拥有状态:时间归 time-model,颜色归 controller,GL 归 renderer,像素取样归 loupe,装配与交互归 main。模块之间只通过构造函数与少量方法耦合,不引入全局单例,也不让任何模块反向依赖 main。

10. 验收

  1. pnpm build 通过(tsc --noEmit 无错)。
  2. pnpm dev 打开页面:钟面正确走时,三根指针、60 + 12 刻度、轴心齐全,表盘水平居中。
  3. 超采样在 1×1 时能明显看到指针斜边与细刻度上的阶梯(就是 1 点/像素的观感),4×4 及以上与解析覆盖肉眼一致;切解析覆盖阶梯消失且灰度过渡自然。
  4. 放大镜里能看到真实设备像素网格,中心像素色值(loupeHex)随位置变化,在指针边缘处读到的是中间灰而不是非黑即白;滚轮以 1 像素为步长改源窗口,下限停在 4px。
  5. 切换主题,钟面配色、控制台配色、放大镜面板配色同时改变,无残留旧色;midnight 等暗色主题下依然清晰。
  6. 拖动可拨三针(抓哪儿拨哪儿)、拖动期间走时冻结且松手不跳、空格暂停、校准、各开关按钮与 aria-pressed 状态一致。
  7. 键盘 0–9、B、T、F、L、G、Space、R、H 各自生效,顺序与面板分组一致。
  8. 收起控制台:面板与它的收起按钮一起退场,提示行留在页面底部,表盘变大;展开后回到原样,浮标横坐标与收起按钮一致。
  9. WebGL2 不可用或着色器编译失败时,fatal 面板给出可读原因,页面不白屏。
  10. pnpm build:standalone 产出唯一的 dist/index.html,外部引用为 0;:minify 变体产出压缩版。

11. 取舍与边界

不做的事:不引入 UI 框架与运行时依赖;不做音频、闹钟、秒表;不做数字表盘;不写数字刻度(钟面保持无数字);不做移动端手势的额外花样(指针事件统一处理即可)。

已知取舍,都是有意选的:

  • 时区是固定整点偏移,不跟夏令时。 换来的是取值直接、下拉与键盘都简单;要 DST 就得改回按地区选、并重新引入 Intl 换算。
  • 读数不带 AM/PM。 12 小时表盘上本来没有这个记号;代价是深夜的一个数字要靠现场判断上下午。
  • 表盘上没有数字。 两档刻度承担了可读性;代价是第一次看到要认一下长短。
  • 收起状态下若窗口被改动大小,浮标横坐标不会自动更新。 面板隐藏时量不到它的右边缘,只能记住收起前的位置;展开一次即重新量准。
  • 秒针默认连续、不做平滑插值。 跳秒只是把秒值取整,用来对照时间轴上的别名;两者共用同一条渲染路径。

12. 来源与致谢

主题 CSS 与主题显示名取自 liliMozi/openhanako(HanaAgent)的主题资源,以 submodule 引用(pin 在 v0.450.0),版权与许可归属上游项目:本项目不复制、不改动它们的内容,只在配色与命名上跟随它。

13. 许可

本项目采用 Mozilla Public License 2.0(MPL-2.0),全文见 LICENSE。

This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0.

Contributors

Languages