一个可交互的反走样时钟。同一套几何、两条采样路径、一个能看设备像素的放大镜,用来把"反走样到底是什么"从口诀变成可量的事实;外观全部由 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 点/像素与 n×n 超采样走的是同一条硬阈值路径,解析覆盖是另一条。切换时几何一个像素都不动,只有覆盖率算法变。
- 一个能看设备像素的放大镜。 真实设备像素被放大、网格画在源像素边界上、中心那一格的十六进制值实时读出。指针边缘"到底是不是灰的"不再靠眼看。
- 时间轴上的对照。 连续秒针与跳秒,是空间锯齿在时间上的同一件事。
观众有两个:写它的人(要能验证、能改),和演示机前的人(分辨率可能不高,配色必须与宿主一致、一眼看得懂)。
复盘的主干。每条写:选了什么、为什么、以及否掉了什么。
用距离场表示几何,而不是路径描边。 三条采样路径都需要"到边缘的距离"这一个量:解析覆盖把它除以像素长度,超采样只需要它的符号。把几何写成 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",两边都不压缩——产物是拿来读的。
片元着色器里:
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)与着色器能用同一个角度。
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会溢出,需要兜底。
距离转覆盖率,这是两条采样路径唯一的分歧点:
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) 合成。
| 层 | 图元 | 颜色 |
|---|---|---|
| 投影 | 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。
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);超采样改为先把每个样本转线性、平均后再转回。
把距离场本身画出来,替代正常着色:取关键图元(刻度、三根指针、轴心)的并集距离 d,
以 t = clamp(d/uPxDial, -6, 6) 在 uAccent(内侧)与 uBg(外侧)之间过渡,
并叠加每 1 像素一条的等距线(abs(fract(d/uPxDial) - 0.5) 靠近 0.5 处混入 uCoral)。
这张图就是"SDF 是光滑的、边缘的锯齿来自把光滑场按像素硬阈值化"的现场证据。
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——没有顶点数据要传,几何全在片元里算。
墙上时刻 = Date.now() + offset + 时区偏移;连续模式秒值带毫秒小数,跳秒模式取整到秒。
- 偏移只由两件事改:拖动拨针(累加),与校准按钮(清零)。
- 时区是纯整点偏移(UTC-11 … UTC+11),初值由浏览器时区取整得到,固定不跟夏令时(代价见 §11)。
- 暂停冻结显示;暂停期间拨针量保留,恢复后从冻结时刻接着走,不追平真实时间。
- 读数 12 小时制
hh:mm:ss.mmm,与 12 小时表盘一致;不带 AM/PM——表盘上本来就没有这个记号。
指针在 dial 内按下,按抓取位置决定拨哪根针:三针各自算指针角度差,越出该针长度重罚,重叠时压在上面的优先(秒 > 分 > 时),取分数最低者。拖动角度差按该针的整圈周期换成时间(时针 12 小时 / 分针 1 小时 / 秒针 1 分钟),累加进偏移。不用修饰键。
拖动期间自动冻结走时(只针对本次拖动,松手从拨好的时刻接着走;你手动按下的暂停不动),否则针一边跑手一边追,对不准。
放大镜读真实设备像素,最近邻放大,网格画在源像素边界上。滚轮在 dial 上调源窗口边长(设备像素),步长 1 像素,从 1 倍(窗口 = 放大镜画布)到 4 像素。用窗口像素数而不是倍数来表达,是因为"一个放大镜像素对应几个源像素"本来就是这件事的实质。
指针离开后停在最后一次位置(用法就是"指住一个点、再去读放大画面");放大镜开着时表盘上隐藏光标,否则指针本身会挡住取样框。
顺序与控制台的分组一致:1–9 设每轴样本数,0 切解析覆盖,B sRGB/线性光,T 跳秒/连续,F 覆盖场,V 放大镜,G 像素网格,Space 暂停/继续,R 校准,H 收起/展开参数。时区与主题只用下拉,不配键;焦点在输入控件上时不拦截。
默认视图落在 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)。
themes/controller.ts(ThemeController)职责:
- 解析主题 id,优先级从高到低:URL
?theme=<id>(只在初次解析时认一次)>localStorage['aaclock:theme']> 系统明暗(matchMedia('(prefers-color-scheme: dark)')命中取midnight,否则warm-paper,与宿主的默认搭配一致)。存储值auto表示跟随系统。无法识别的 id 忽略,继续往下回退。 - 应用:
documentElement.dataset.theme = id(CSS 自动生效)+documentElement.style.colorScheme = dark ? 'dark' : 'light'(原生控件配色)。暗色判定由底色亮度反推,省掉一张"哪些主题是暗色"的表。 - 读取:
getComputedStyle(documentElement)取变量,转成 GL palette 交给renderer。只在此处、只在应用主题时读一次,不每帧读。 themeSelect首项为"自动",其余列出清单里的主题(themes/catalog.ts),显示名取上游 locale 的中文名(构建期摘取,§5.1);切换即写回localStorage。- 处于
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 时放大,改半径时相关内缩量会自动跟上。
| 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) |
版面自上而下:顶栏浮在表盘区之上(不占行);中部表盘 严格居中,放大镜挂在表盘右边缘之外;底部是"提示行 + 参数控制台",按内容收窄、整体居中。
- 顶栏浮层:表盘四周本来留有一圈投影余地(
FIT = 0.84),顶栏压在那圈余地上不会盖住盘面,却把一整行高度还给表盘。小窗口下先吃留白、再压阴影圈,约 420px 高的窗口才会碰到盘面。浮层加pointer-events: none,不在表盘上方吃指针事件。 - 放大镜在盘外:它比表盘更挡视线,而它只是工具。挂法是在表盘的
left: 100%之外,绝对定位不参与布局,所以表盘不被它推偏;右侧放不下时加loupe-overlay收回盘内叠放。判定在resize里实算(表盘宽 + 面板宽 + 边距 vs 视口宽),不用写死的断点。 - 底部块:提示与收起按钮同一行,左侧用等宽占位保证提示仍在整体居中;整块按内容收窄,于是那行的右端与面板右缘对齐。收起按钮属于面板,面板一收它随之退场;提示行是面板之外的东西,留在页面底部。
- 收起让位:收起时表盘上限从 72vh 提到 86vh,并把尺寸对齐立刻重跑一次(不等
ResizeObserver)。浮标的横坐标在收起那一刻从收起按钮量下来,点同一个位置就能来回切。
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 像素坐标。
命令见文首。两种构建都落在 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。
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 末尾,不覆盖既有选择器语义。
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。
pnpm build通过(tsc --noEmit无错)。pnpm dev打开页面:钟面正确走时,三根指针、60 + 12 刻度、轴心齐全,表盘水平居中。- 超采样在 1×1 时能明显看到指针斜边与细刻度上的阶梯(就是 1 点/像素的观感),4×4 及以上与解析覆盖肉眼一致;切解析覆盖阶梯消失且灰度过渡自然。
- 放大镜里能看到真实设备像素网格,中心像素色值(
loupeHex)随位置变化,在指针边缘处读到的是中间灰而不是非黑即白;滚轮以 1 像素为步长改源窗口,下限停在 4px。 - 切换主题,钟面配色、控制台配色、放大镜面板配色同时改变,无残留旧色;
midnight等暗色主题下依然清晰。 - 拖动可拨三针(抓哪儿拨哪儿)、拖动期间走时冻结且松手不跳、空格暂停、校准、各开关按钮与
aria-pressed状态一致。 - 键盘
0–9、B、T、F、L、G、Space、R、H各自生效,顺序与面板分组一致。 - 收起控制台:面板与它的收起按钮一起退场,提示行留在页面底部,表盘变大;展开后回到原样,浮标横坐标与收起按钮一致。
- WebGL2 不可用或着色器编译失败时,
fatal面板给出可读原因,页面不白屏。 pnpm build:standalone产出唯一的dist/index.html,外部引用为 0;:minify变体产出压缩版。
不做的事:不引入 UI 框架与运行时依赖;不做音频、闹钟、秒表;不做数字表盘;不写数字刻度(钟面保持无数字);不做移动端手势的额外花样(指针事件统一处理即可)。
已知取舍,都是有意选的:
- 时区是固定整点偏移,不跟夏令时。 换来的是取值直接、下拉与键盘都简单;要 DST 就得改回按地区选、并重新引入
Intl换算。 - 读数不带 AM/PM。 12 小时表盘上本来没有这个记号;代价是深夜的一个数字要靠现场判断上下午。
- 表盘上没有数字。 两档刻度承担了可读性;代价是第一次看到要认一下长短。
- 收起状态下若窗口被改动大小,浮标横坐标不会自动更新。 面板隐藏时量不到它的右边缘,只能记住收起前的位置;展开一次即重新量准。
- 秒针默认连续、不做平滑插值。 跳秒只是把秒值取整,用来对照时间轴上的别名;两者共用同一条渲染路径。
主题 CSS 与主题显示名取自 liliMozi/openhanako(HanaAgent)的主题资源,以 submodule 引用(pin 在 v0.450.0),版权与许可归属上游项目:本项目不复制、不改动它们的内容,只在配色与命名上跟随它。
本项目采用 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.