拿一张手表的图给 AI 看,说"做出这个天文机械表"。三个字的需求,三天断断续续的对话,最终产出了一个纯原生、零依赖、单文件的交互式天文腕表。这篇文章记录了整个过程中的技术选型、核心算法实现和踩过的坑。
前言
事情的起因很简单。我在 Trae Work 里丢了一张天文腕表的图片,配了一句需求:"做出这个天文机械表"
图片上的需求说明写得很清楚:
- 主表盘显示时、分、秒,秒针需要平滑扫动
- 月相子表盘实时显示当前月相
- 计时码表功能(开始/暂停/计圈/归零)
- 日期窗显示当前日期与星期
- 昼夜指示子表盘根据经纬度显示当地日出日落
- 响应式设计 + 无障碍支持(屏幕阅读器、降低动效偏好)
- 视觉风格:金色机械美学,高级腕表质感
听起来像是"把一个瑞士钟表匠的活儿塞进浏览器里"。而 Trae Work 需要在零框架、零库、零 CDN 的约束下,用纯 HTML + CSS + Canvas 把这件事干完。
最终产物长这样:一个 68KB 的单文件 HTML,双击即开,不依赖任何外部资源,天文算法与 NOAA 日出日落方程全部自研内联。
技术选型:为什么是纯 Canvas + 双缓冲
机械腕表的视觉复杂度远超普通 UI 组件。表盘上有几十层同心环、放射状玑镂纹、黄道十二宫扇区、月份环、四个子表盘——如果用 DOM 元素堆叠,性能和精度都会崩。Canvas 是唯一合理的选择。
但 Canvas 有个老问题:每帧重绘全部内容太贵。解决方案是双缓冲:
┌─────────────────────────────────────┐
│ 静态层 (staticLayer) │
│ 预渲染一次,缓存为离屏 Canvas │
│ · 玑镂纹(180 条同心圆 + 180 条放射线)│
│ · 黄道十二宫环(符号 + 中文名) │
│ · 月份环(英文缩写 + 天数) │
│ · 分钟轨(60 根刻度) │
│ · 子表盘外框 + 刻度 │
│ · 品牌铭文(沿弧排布文字) │
├─────────────────────────────────────┤
│ 动态层 (每帧重绘) │
│ · 黄道高亮扇区 + 日月指针 │
│ · 月相绘制(终结线椭圆几何) │
│ · 日期滚动鼓 │
│ · 昼夜弧 + 24h 时针 │
│ · 计时秒针 │
│ · 时/分/秒三针 + 中心宝石 │
└─────────────────────────────────────┘
核心就两行代码:
ctx.clearRect(0, 0, size, size);
ctx.drawImage(staticLayer, 0, 0, size, size); // 把缓存层拍上去
// 然后只画动态部分...
静态层在尺寸变化时才重建,动态层每帧只画指针和子表盘内容。这样即使在 2x DPR 的高分屏上也能稳定 60fps。
天文计算:从 Kepler 到 Meeus
这块表最硬核的部分不是画图,是算天文位置。表盘上的日月指针指向黄道十二宫,月相需要精确计算太阳和月球的黄经差。
太阳视黄经
太阳黄经用的是 VSOP87 的简化式,精度在公元 1950–2050 范围内约 0.01°:
function sunLongitude(jd) {
var T = (jd - 2451545.0) / 36525; // 儒略世纪
var L0 = norm360(280.46646 + 36000.76983 * T + 0.0003032 * T * T);
var M = norm360(357.52911 + 35999.05029 * T - 0.0001537 * T * T);
var Mr = M * D2R;
// 中心差(地球椭圆轨道修正)
var C = (1.914602 - 0.004817 * T) * Math.sin(Mr)
+ 0.019993 * Math.sin(2 * Mr)
+ 0.000289 * Math.sin(3 * Mr);
// 章动修正
var omega = 125.04 - 1934.136 * T;
return norm360(L0 + C - 0.00569 - 0.00478 * Math.sin(omega * D2R));
}
L0 是平黄经,M 是平近点角,C 是中心差——这三项加起来就是太阳的实际黄经。最后两项 0.00569 和章动修正是为了提高精度,去掉也行,但加上了会更准。
月球视黄经
月球复杂得多。它的轨道受太阳摄动严重,主要周期项就有几十个。我截断了 Meeus《天文算法》第 47 章的前 17 项摄动:
function moonLongitude(jd) {
var T = (jd - 2451545.0) / 36525;
// 五个基础参数
var Lp = norm360(218.3164477 + 481267.88123421 * T); // 月球平黄经
var D = norm360(297.8501921 + 445267.1114034 * T); // 月日平距角
var M = norm360(357.5291092 + 35999.0502909 * T); // 太阳平近点角
var Mp = norm360(134.9633964 + 477198.8675055 * T); // 月球平近点角
var F = norm360(93.2720950 + 483202.0175233 * T); // 升交点距
var lon = Lp
+ 6.288774 * Math.sin(Mp) // 主椭圆项(最大)
+ 1.274027 * Math.sin(2 * D - Mp) // 出差
+ 0.658314 * Math.sin(2 * D) // 二均差
+ 0.213618 * Math.sin(2 * Mp) // 第二椭圆项
// ... 还有 13 项
;
return norm360(lon);
}
截断到 17 项的精度大约 0.03°,对腕表显示来说绑绑有余——人眼根本看不出 0.03° 的差异。
月相:终结线椭圆几何
有了日月黄经,月相就很简单了。黄经差 = 月球黄经 - 太阳黄经,0° 是朔(新月),180° 是望(满月)。被照亮比例用一个余弦公式:
var elong = norm360(moonLon - sunLon);
var illum = (1 - Math.cos(elong * D2R)) / 2; // 0..1
但画出月相才是难点。关键观察:月相的明暗分界线(terminator)在月球盘面上的投影是一条半椭圆。当照亮比例 k 从 0 到 1 变化时,这条椭圆的半轴 a = R × |2k - 1| 在 0 到 R 之间变化。
var a = mr * Math.abs(2 * k - 1); // mr = 月球半径
if (k >= 0.5) {
// 盈月:右半圆(亮) + 左侧椭圆弧
g.arc(p.x, p.y, mr, -Math.PI/2, Math.PI/2, false);
g.ellipse(p.x, p.y, a, mr, 0, Math.PI/2, Math.PI * 1.5, false);
} else {
// 亏月:左侧椭圆弧 + 右半圆(暗)
g.ellipse(p.x, p.y, a, mr, 0, -Math.PI/2, Math.PI/2, false);
g.arc(p.x, p.y, mr, Math.PI/2, -Math.PI/2, true);
}
上弦月(k=0.5)时 a=0,椭圆退化为直线,正好是右半圆亮——完美符合直觉。
NOAA 日出日落方程
昼夜子表盘需要根据经纬度计算当地日出日落。这用的是 NOAA 的标准算法:
- 计算太阳赤纬 δ(由黄经和黄赤交角推导)
- 计算均时差 EqT(太阳实际位置与钟表时间的差异)
- 求解日出日落时角:
cos(H) = (cos(90.833°) - sin(φ)sin(δ)) / (cos(φ)cos(δ))
function solarEvents(date, lat, lon, tzHours) {
// ...省略获取当地正午 UTC 时刻的代码
var geo = solarGeometry(julian(noonUTC));
var cosH = (Math.cos(90.833 * D2R)
- Math.sin(lat * D2R) * Math.sin(geo.decl * D2R))
/ (Math.cos(lat * D2R) * Math.cos(geo.decl * D2R));
if (cosH >= 1) return { polar: 'night' }; // 极夜
if (cosH <= -1) return { polar: 'day' }; // 极昼
var H = Math.acos(cosH) * R2D;
var rise = noonUTCmin - 4 * H; // 日出(度转分钟)
var set = noonUTCmin + 4 * H; // 日落
return { rise: rise, set: set, noon: noonLocal };
}
90.833° 是太阳中心相对于地平线的角度(考虑了大气折射和太阳视半径)。极区判断也在这里处理了:cosH >= 1 意味着太阳永远不升起(极夜),cosH <= -1 意味着太阳永远不落下(极昼)。
我用 Node 实测验证了一遍:
| 城市 | 日出 | 日落 | 实际值 |
|---|---|---|---|
| 北京 09-28 | 06:08 | 18:03 | 吻合 |
| 伦敦 09-28 | 05:57 | 17:46 | 吻合(BST+1) |
| 纽约 09-28 | 06:50 | 18:43 | 吻合(EDT-4) |
| 东京 09-28 | 05:34 | 17:31 | 吻合 |
斯瓦尔巴 6 月判定极昼、12 月判定极夜,也对了。
计时码表:一个不能有 bug 的状态机
计时码表看似简单,但边界条件多。用户可以:
- 在运行中点"开始"→ 应该暂停
- 在暂停中点"开始"→ 应该继续
- 在未开始时点"计圈"→ 不应该响应
- 点"归零"→ 不管什么状态都重置
最容易出 bug 的写法是用一堆 if-else 堆叠。更安全的做法是封装成状态机,每个入口加守卫:
var chrono = {
running: false,
accumulated: 0, // 暂停时冻结的已计时长
startStamp: 0, // 本次开始的时间戳
laps: [],
lastLapTotal: 0
};
function chronoElapsed() {
// 运行中:累计 + 当前段;暂停:只返回累计
return chrono.running
? chrono.accumulated + (performance.now() - chrono.startStamp)
: chrono.accumulated;
}
function chronoToggle() {
if (chrono.running) chronoPause();
else chronoStart();
}
function chronoLap() {
if (!chrono.running) return; // 守卫:未运行不记录
var t = chronoElapsed();
var split = t - chrono.lastLapTotal;
if (split < 30) return; // 守卫:防误触(30ms 以下忽略)
chrono.laps.unshift({ n: chrono.laps.length + 1, total: t, split: split });
chrono.lastLapTotal = t;
}
关键设计:计时值不是"累加"的,而是每次都从 performance.now() 重新算。这样即使浏览器节流、标签页切到后台再回来,时间也不会漂移。visibilitychange 事件还能触发一次即时校准。
CSS 表壳:用纯样式模拟金属质感
Canvas 只负责表盘内部,表壳的金属质感完全靠 CSS:
/* 币纹外圈:交替深浅条纹模拟滚花 */
.case {
background: repeating-conic-gradient(from 0deg,
#4e3a14 0deg 1.2deg,
#e8d191 1.2deg 2.4deg,
#6b5220 2.4deg 3.6deg,
#cba751 3.6deg 4.8deg);
}
/* 抛光表圈:金色锥形渐变模拟金属高光 */
.bezel {
background: conic-gradient(from 205deg,
#43300f 0%, #c9a24a 7%, #fbf3d2 13%, #a9822f 21%,
#61461a 29%, #dcbe6e 38%, #fdf8de 45%, #9c7a2c 53%,
#513a16 61%, #d3ad55 71%, #f6e9ba 78%, #8a6a24 86%, #43300f 100%);
}
/* 蓝宝石镜面:径向 + 线性渐变叠加模拟玻璃反光 */
.glass {
background:
radial-gradient(122% 92% at 27% 10%,
rgba(255,255,255,.155) 0%, rgba(255,255,255,0) 50%),
linear-gradient(126deg,
rgba(255,255,255,.085) 0%, rgba(255,255,255,0) 33%,
rgba(255,255,255,0) 61%, rgba(255,255,255,.065) 100%);
}
币纹用 repeating-conic-gradient 做交替条纹,表圈用 conic-gradient 做不对称高光带(模拟光源从左上方照射),蓝宝石镜面叠了两层渐变做出弧面玻璃的反光效果。
指针:渐变填充模拟抛光金属
三根指针不是简单的矩形,而是菱形截面 + 渐变填充:
function handShape(g, angle, len, halfW, tail) {
g.save();
g.rotate(angle);
g.beginPath();
g.moveTo(0, tail); // 尾部
g.lineTo(-halfW, 0); // 左肩
g.lineTo(-halfW * 0.30, -len * 0.62); // 收腰
g.lineTo(0, -len); // 尖端
g.lineTo(halfW * 0.30, -len * 0.62); // 右收腰
g.lineTo(halfW, 0); // 右肩
g.closePath();
// 横向渐变模拟金属高光
var grad = g.createLinearGradient(-halfW, 0, halfW, 0);
grad.addColorStop(0.00, '#8a6a24');
grad.addColorStop(0.26, '#e6cd8e');
grad.addColorStop(0.50, '#fff8e2'); // 中脊最亮
grad.addColorStop(0.74, '#d9b45e');
grad.addColorStop(1.00, '#6b5220');
g.fillStyle = grad;
g.fill();
g.restore();
}
中脊高光 + 两侧暗色的渐变,模拟的是金属指针在光线下"中间亮、两边暗"的真实反射效果。
日期滚动鼓:数字翻转效果
日期窗不是直接显示当前日期,而是模拟机械表的"数字鼓"——五个日期数字垂直排列,当前日期居中,前后日期以渐变透明度隐约可见:
for (var d = day - 2; d <= day + 2; d++) {
var offs = d - day;
var fade = 1 - Math.min(1, Math.abs(offs) / 2.4);
g.fillStyle = 'rgba(243,236,220,' + (offs === 0 ? 0.98 : 0.30 * fade + 0.10) + ')';
g.fillText(pad2(real), numX, p.y + offs * rowH);
}
配合鼓面顶底的暗色渐变蒙版,就有了一种数字在滚筒上翻转的立体感。
踩坑记录
1. 沿弧排布文字的方向问题
品牌铭文 "ASTROLABE" 要沿圆弧排布,一开始用 arcText() 函数在底部排了 "ASTRONOMIQUE",结果文字是倒着的。
原因:沿弧排布时,如果弧的方向是顺时针(从左到右经底部),文字基线朝外,就是倒的。解决方案是底部文字直接用水平排列,只在顶部用弧形排布。
2. 子表盘刻度被昼夜弧覆盖
昼夜子表盘的 24 小时刻度本来画在静态层,结果被动态层的昼夜色弧盖住了。解决方案:把刻度移到动态层,在画完昼夜弧之后再画刻度,保证叠在上层。
3. 黄道指针的冗余代码
最初写黄道指针时残留了一组多余的 moveTo + closePath,画出了一个肉眼不可见但会偶发闪烁的空路径。清理后只保留三角形绘制,问题消失。
4. 时区与夏令时
四城市切换不能简单用固定时区偏移。伦敦和纽约有夏令时规则:
- 欧洲:3 月最后一个星期日 01:00 UTC 开始,10 月最后一个星期日 01:00 UTC 结束
- 美国:3 月第二个星期日 02:00 本地时间开始,11 月第一个星期日 02:00 本地时间结束
两地开始时间不同(欧洲用 UTC,美国用本地时间),边界处理写错了会差一小时。
5. 长时间运行的秒针漂移
如果用累加方式计时(start + (now - start)),在浏览器后台节流时会偏差。解决方案:每帧从 Date.now() 重新计算时间,而不是累加。visibilitychange 回来时做一次即时校准。
无障碍:不只是装饰品
天文腕表的视觉信息密度很高,但屏幕阅读器用户无法"看"到 Canvas。解决方案是维护一个隐藏的 aria-live="polite" 区域,每分钟更新一次文字播报:
elA11y.textContent =
'主表盘 14 时 32 分 15 秒,2026 年 9 月 28 日 星期一,' +
'太阳位于天秤座。' +
'月相表盘:满月,照亮 97%,月龄 16.3 天。' +
'日期表盘:28 日,星期一。' +
'昼夜表盘:北京 当地 14:32,日出 06:08,日落 18:03。' +
'计时表盘:00:00.00。';
同时,prefers-reduced-motion 偏好开启时,秒针从平滑扫动切换为跳秒(每秒一跳),并关闭入场动画和镜面装饰效果,减少视觉刺激。
最终效果
打开 HTML 文件,你会看到:
- 表壳:币纹外圈 + 抛光金色表圈 + 蓝宝石镜面反光
- 表盘:午夜蓝玑镂纹底色,同心环 + 放射状纹理
- 外环:黄道十二宫(♈♉♊... 符号 + 中文名)与月份环(JAN-DEC + 天数)
- 子表盘:左上月相(星点夜空 + 照亮面 + 环形山)、右上日期窗(滚动鼓 + 星期栏)、左下昼夜弧(白昼金/夜晚蓝 + 日出日落标记)、右下计时码表(蓝钢指针)
- 中心:三根金色指针 + 中心宝石
- 右侧面板:数字时钟、日出日落信息卡(四城市切换)、月相详情卡、计时码表面板(计圈列表)
整个文件 68KB,零外部依赖,零网络请求,双击即开。
写在最后
这个项目让我重新认识了一件事: "天文"不是装饰词。
一块真正的天文腕表,它的月相不是随便画个弯月,而是要精确计算太阳和月球的黄经差;它的日出日落不是查表硬编码,而是要根据经纬度和日期实时解算;它的日期不是 new Date().getDate() 一行了事,而是要处理闰年、滚动鼓、星期对齐。
Trae Work 在这个过程中做的事情:从一张图读懂需求,在天文算法上选对了 Meeus 和 NOAA,在渲染上选对了 Canvas 双缓冲,在状态管理上选对了状态机模式,在视觉上选对了金色机械美学。这些判断不是堆叠 API 能做到的,需要对问题域的理解。