一张图三句需求,我用 Trae Work 做了一块能看日出日落和月相的天文机械表

拿一张手表的图给 AI 看,说"做出这个天文机械表"。三个字的需求,三天断断续续的对话,最终产出了一个纯原生、零依赖、单文件的交互式天文腕表。这篇文章记录了整个过程中的技术选型、核心算法实现和踩过的坑。

前言

事情的起因很简单。我在 Trae Work 里丢了一张天文腕表的图片,配了一句需求:"做出这个天文机械表"

图片上的需求说明写得很清楚:

  1. 主表盘显示时、分、秒,秒针需要平滑扫动
  2. 月相子表盘实时显示当前月相
  3. 计时码表功能(开始/暂停/计圈/归零)
  4. 日期窗显示当前日期与星期
  5. 昼夜指示子表盘根据经纬度显示当地日出日落
  6. 响应式设计 + 无障碍支持(屏幕阅读器、降低动效偏好)
  7. 视觉风格:金色机械美学,高级腕表质感

听起来像是"把一个瑞士钟表匠的活儿塞进浏览器里"。而 Trae Work 需要在零框架、零库、零 CDN 的约束下,用纯 HTML + CSS + Canvas 把这件事干完。

最终产物长这样:一个 68KB 的单文件 HTML,双击即开,不依赖任何外部资源,天文算法与 NOAA 日出日落方程全部自研内联。

技术选型:为什么是纯 Canvas + 双缓冲

机械腕表的视觉复杂度远超普通 UI 组件。表盘上有几十层同心环、放射状玑镂纹、黄道十二宫扇区、月份环、四个子表盘------如果用 DOM 元素堆叠,性能和精度都会崩。Canvas 是唯一合理的选择。

但 Canvas 有个老问题:每帧重绘全部内容太贵。解决方案是双缓冲:

scss 复制代码
┌─────────────────────────────────────┐
│  静态层 (staticLayer)                │
│  预渲染一次,缓存为离屏 Canvas        │
│  · 玑镂纹(180 条同心圆 + 180 条放射线)│
│  · 黄道十二宫环(符号 + 中文名)       │
│  · 月份环(英文缩写 + 天数)           │
│  · 分钟轨(60 根刻度)                │
│  · 子表盘外框 + 刻度                  │
│  · 品牌铭文(沿弧排布文字)           │
├─────────────────────────────────────┤
│  动态层 (每帧重绘)                    │
│  · 黄道高亮扇区 + 日月指针             │
│  · 月相绘制(终结线椭圆几何)          │
│  · 日期滚动鼓                         │
│  · 昼夜弧 + 24h 时针                  │
│  · 计时秒针                           │
│  · 时/分/秒三针 + 中心宝石            │
└─────────────────────────────────────┘

核心就两行代码:

arduino 复制代码
ctx.clearRect(0, 0, size, size);
ctx.drawImage(staticLayer, 0, 0, size, size);  // 把缓存层拍上去
// 然后只画动态部分...

静态层在尺寸变化时才重建,动态层每帧只画指针和子表盘内容。这样即使在 2x DPR 的高分屏上也能稳定 60fps。

天文计算:从 Kepler 到 Meeus

这块表最硬核的部分不是画图,是算天文位置。表盘上的日月指针指向黄道十二宫,月相需要精确计算太阳和月球的黄经差。

太阳视黄经

太阳黄经用的是 VSOP87 的简化式,精度在公元 1950--2050 范围内约 0.01°:

ini 复制代码
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 项摄动:

scss 复制代码
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° 是望(满月)。被照亮比例用一个余弦公式:

ini 复制代码
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 之间变化。

javascript 复制代码
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 的标准算法:

  1. 计算太阳赤纬 δ(由黄经和黄赤交角推导)
  2. 计算均时差 EqT(太阳实际位置与钟表时间的差异)
  3. 求解日出日落时角:cos(H) = (cos(90.833°) - sin(φ)sin(δ)) / (cos(φ)cos(δ))
javascript 复制代码
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 堆叠。更安全的做法是封装成状态机,每个入口加守卫:

php 复制代码
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:

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 做不对称高光带(模拟光源从左上方照射),蓝宝石镜面叠了两层渐变做出弧面玻璃的反光效果。

指针:渐变填充模拟抛光金属

三根指针不是简单的矩形,而是菱形截面 + 渐变填充:

scss 复制代码
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();
}

中脊高光 + 两侧暗色的渐变,模拟的是金属指针在光线下"中间亮、两边暗"的真实反射效果。

日期滚动鼓:数字翻转效果

日期窗不是直接显示当前日期,而是模拟机械表的"数字鼓"------五个日期数字垂直排列,当前日期居中,前后日期以渐变透明度隐约可见:

ini 复制代码
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" 区域,每分钟更新一次文字播报:

arduino 复制代码
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 能做到的,需要对问题域的理解。

相关推荐
mudtools1 小时前
在.NET现有系统中快速集成飞书任务分配能力
后端·c#·.net
知守观1 小时前
ThreadLocal + 异步线程导致用户数据串号:一次跨请求数据泄漏的完整复盘
后端
前端冒菜师1 小时前
我为什么做了 Iris,又为什么停下了它
后端·ai编程
子一!!1 小时前
集成Spring家族的Spring论坛实战==一阶段
java·后端·spring
Ticnix1 小时前
你的 Agent 聊到第 20 轮就"失忆"?你管理的是历史,高手管理的是上下文
后端·python·agent
不合格的程序员1 小时前
Agent Memory架构设计与实现
后端·ai编程
小小善后师1 小时前
用 Canvas + AI 实现登录页的 Logo 粒子动画
前端·vue.js
Amos_Web1 小时前
Rspack 源码解析(十六):多类型资源如何进入 Compilation.assets
前端·rust·前端框架
GAMC1 小时前
chrome-devtools-mcp:让 AI 编码助手真正"看见"浏览器
前端·人工智能