北极星为什么不落:我用华为云码道把拱极判定做成了天球实验室

一键开通华为云码道 CodeArts 代码智能体:https://developer.huaweicloud.com/codeartsco.html?source=dmzntgwatomgit1&sourcead=dmzntgwatomgithd

秋夜抬头,北斗围着北极转,织女会西沉,十字架二在北京根本看不见。教材上有一句很短的话:赤纬够高的星永不落下,叫拱极星。公式也不长——

sin ⁡ a = sin ⁡ φ sin ⁡ δ + cos ⁡ φ cos ⁡ δ cos ⁡ H \sin a = \sin\varphi\sin\delta + \cos\varphi\cos\delta\cos H sina=sinφsinδ+cosφcosδcosH

上中天 H = 0 H=0 H=0,下中天 H = 180 ∘ H=180^\circ H=180∘。比较两次高度的正负,就能判定这颗星是永不落下、有升有落,还是永不升起。

网上一搜「拱极星」,出来的要么是星图 App,一开口就要定位、要联网星表;要么是一段动画,高度从哪来的说不清。于是我把这件事做成一个网页:拱极星实验室。开发全程交给 华为云码道 CodeArts,模型用的是 GLM-5.2。我只负责把分层、公式和禁令写死,把仓库拉回本地点一遍。

这不是观星软件,也不是望远镜指向器。页面上只有一件事:给定观测纬度 φ \varphi φ 和恒星赤纬 δ \delta δ,看这颗星会不会落。赤经、日期、岁差、大气折射全部砍掉。砍完之后,判定只剩两个数的符号。
在这里插入图片描述

作品介绍:松烟墨底上的子午圈

打开页面就能做这几件事:

  1. 选一座中国省级驻地(去重后 31 个示意坐标),默认北京。
  2. 选一颗亮星(20 颗,含北极星、织女、天狼、十字架二),或自己填 φ \varphi φ、 δ \delta δ。
  3. 立刻看到上中天高度 a max ⁡ a_{\max} amax​、下中天高度 a min ⁡ a_{\min} amin​、判定(拱极 / 有升有落 / 永不升起)。
  4. 若有升有落,额外给出升出时角 H r i s e H_{\mathrm{rise}} Hrise​(小时)和升出方位 A r i s e A_{\mathrm{rise}} Arise​(从北向东)。
  5. Canvas 画一张子午圈侧视图:圆是天球,水平直径是地平,上方是可见天空。赤纬圈画成朱砂虚线椭圆;拱极时整圈在地平上,永不升起时整圈在地平下。
  6. 可选填「我目测 / 查阅的上中天高度」,对照模型差值。
  7. ↑ / ↓ 微调纬度,输入框聚焦时不抢键。偏好写入 localStorage,失败静默。

页脚写得很清楚:教学近似模型,球面天文,岁差与自行忽略,非观测星历,不能用于导航或望远镜指向。

默认首屏:松烟墨底,北京示意,北极星判定永不落下

图 1:首屏 3 秒内能看懂四件事——这座城、这颗星、上中天多高、它会不会落。配色是 #0c0d10 松烟墨、#e8e4d9 绢白、#c45c4a 朱砂、#4a7c9b 石青。默认北京 φ = 39.9042 ∘ \varphi=39.9042^\circ φ=39.9042∘、北极星 δ = 89.26 ∘ \delta=89.26^\circ δ=89.26∘,判定「永不落下(拱极)」, a max ⁡ ≈ 40.64 ∘ a_{\max}\approx 40.64^\circ amax​≈40.64∘, a min ⁡ ≈ 39.16 ∘ a_{\min}\approx 39.16^\circ amin​≈39.16∘。下中天仍在地平上,这就是「围着天极转、永不落下」的几何含义。

把恒星换成天狼( δ = − 16.72 ∘ \delta=-16.72^\circ δ=−16.72∘),判定立刻翻成「有升有落」。上中天还在地平上,下中天已经钻到地下。升出时角大约 5.03 小时,升出方位大约 112 ∘ 112^\circ 112∘(从北向东,也就是东南偏东)。

北京看天狼:有升有落,上中天 33.38°,下中天 −66.82°

图 2:同一座城,换一颗南边的亮星。赤纬圈和地平相交,Canvas 上能看见路径跨过水平直径。 H r i s e H_{\mathrm{rise}} Hrise​ 和 A r i s e A_{\mathrm{rise}} Arise​ 只在这种状态下有值;拱极和永不升起时显示「无升落」,避免对 ∣ cos ⁡ H ∣ > 1 |\cos H|>1 ∣cosH∣>1 强行 acos。

再换成十字架二(Acrux, δ = − 63.10 ∘ \delta=-63.10^\circ δ=−63.10∘),北京这纬度上中天已经是 − 13.00 ∘ -13.00^\circ −13.00∘。整圈在地平下,判定「永不升起」。南十字在北半球中纬度看不见,不是星图没画,是球面几何不允许。

北京看十字架二:永不升起,上中天 −13.00°

图 3:aria-live 播报判定。朱砂色用来标「看不见」。公式在 domain 里算完,UI 只负责把枚举值画出来。

把 φ \varphi φ、 δ \delta δ 都填 0——赤道上看天赤道星。上中天 90.00 ∘ 90.00^\circ 90.00∘,下中天 − 90.00 ∘ -90.00^\circ −90.00∘,升出时角恰好 6 小时,方位 90 ∘ 90^\circ 90∘(正东)。0 在这里是合法物理点,不是空值。

赤道 φ=0°、天赤道 δ=0°:上中天 90°,下中天 −90°,有升有落

图 4:这组数是提示词里的尺子。测过它,才敢说「空字符串回退」和「合法的 0」被拆开了。

为什么做拱极,而不是再做一块星图

码道很容易把「这颗星会不会落」做成申请定位的观星 App,或者一个 fetch 亮星星表的 Canvas 演示。那些题一开口就要网、要权限。拱极实验室卡的是另一类问题:

  • 公式必须在纯函数里。 a max ⁡ a_{\max} amax​、 a min ⁡ a_{\min} amin​、判定、升出时角一旦写进点击事件,测试就得启动浏览器。
  • 0 是合法物理点。 纬度 0 是赤道,赤纬 0 是天赤道。Number("") === 0。空输入如果当成 0,人会被按到赤道上,星会被按到天赤道上。
  • 天极和极地不能除零。 cos ⁡ φ = 0 \cos\varphi=0 cosφ=0 时升出方位公式 cos ⁡ A = sin ⁡ δ / cos ⁡ φ \cos A=\sin\delta/\cos\varphi cosA=sinδ/cosφ 会爆。 ∣ tan ⁡ φ tan ⁡ δ ∣ > 1 |\tan\varphi\tan\delta|>1 ∣tanφtanδ∣>1 时 acos 会出 NaN。
  • 星历口吻必须拆掉。 城市坐标是示意值,赤经赤纬是 J2000 近似,忽略岁差与自行。免责声明必须写在首屏和页脚,不能只藏在 README。

评审维度里架构、代码、安全加起来 80 分。拱极这件事刚好能把这三项写进同一条数据流:事件 → sanitize → domain → render。天球盘只是把这条流露出来。

几何上可以更直白一点。上中天时角 H = 0 H=0 H=0,正弦公式退化成余弦差:

sin ⁡ a max ⁡ = cos ⁡ ( φ − δ ) \sin a_{\max} = \cos(\varphi-\delta) sinamax​=cos(φ−δ)

当 ∣ φ − δ ∣ ≤ 90 ∘ |\varphi-\delta|\le 90^\circ ∣φ−δ∣≤90∘,就是教材里那句「上中天高度等于 90 ∘ − ∣ φ − δ ∣ 90^\circ-|\varphi-\delta| 90∘−∣φ−δ∣」。北京看北极星: 90 ∘ − ∣ 39.90 ∘ − 89.26 ∘ ∣ = 40.64 ∘ 90^\circ-|39.90^\circ-89.26^\circ|=40.64^\circ 90∘−∣39.90∘−89.26∘∣=40.64∘,和下中天 39.16 ∘ 39.16^\circ 39.16∘ 都还在地平上,所以整圈不落。北京看十字架二: 90 ∘ − ∣ 39.90 ∘ − ( − 63.10 ∘ ) ∣ = − 13.00 ∘ 90^\circ-|39.90^\circ-(-63.10^\circ)|=-13.00^\circ 90∘−∣39.90∘−(−63.10∘)∣=−13.00∘,上中天已经在地下,南十字在这纬度看不见。数字对上了,Canvas 才有资格画那条朱砂椭圆。

一、先把公开仓建出来

参赛要求写得很死:AtomGit 公开仓、可运行源码、README、开源协议。我从 https://atomgit.com/dashboard 进工作台,切到 华为云码道。左侧能看到账号 @weixin_52908342。模型选用 GLM-5.2。作品不绑定某一个模型名,绑定的是需求写得清不清楚。

华为云码道工作台,左侧可见 AtomGit 账号 @weixin_52908342

图 5:码道 Agent 界面。截图里带有 AtomGit 账号。模型在输入框旁选 GLM-5.2。

问卷仍然克制:托管平台选 AtomGit,仓库类型选 Other 以便自定义名称,仓库名填 circumpolar-lab,可见性选公开,初始化 README / .gitignore / MIT LICENSE。名称必须先想好。star-map 太像观星软件,polaris 只覆盖一颗星,circumpolar-lab 能同时说出拱极和实验室这两个词。

新对话首页,模型 GLM-5.2

图 6:建仓用新对话。上一件作品的上下文会把仓库名带偏。

问卷选择 AtomGit 作为托管平台

图 7:平台先问清楚,再动远端。AtomGit 是参赛硬条件,不能等到开发完再迁仓。

仓库问卷:名称 circumpolar-lab

图 8:自定义仓库名。my-project 这种占位符不能带进提交记录。

码道调用 ag CLI 建仓、写 README / .gitignore / MIT LICENSE、推到 main。这一步没有业务代码,只有空仓和基础文件。

码道初始化公开仓:README、.gitignore、MIT

图 9:公开仓从第一秒就是公开仓。私有仓建完再改可见性,评审打开时可能还是 404。

码道确认公开仓库已创建

图 10:地址就是后面一直用的 https://atomgit.com/weixin_52908342/circumpolar-lab。建仓那一轮会吞掉上下文,所以开发需求我放到了绑定同一仓库的新对话里发。

二、提示词把公式和禁令钉死

绑定 weixin_52908342/circumpolar-lab 之后,我把完整需求贴进去。如果只说「做个看星星的网站」,很容易拿到一个申请定位的星图,或者一个把 asin 写进 onclick 的 Canvas 演示。

我钉死的几条:

  • 分层:src/domain 纯计算、src/security 消毒、src/services 存偏好、src/ui 只负责画。
  • 公式必须按提示词原文实现: sin ⁡ a max ⁡ = sin ⁡ φ sin ⁡ δ + cos ⁡ φ cos ⁡ δ \sin a_{\max}=\sin\varphi\sin\delta+\cos\varphi\cos\delta sinamax​=sinφsinδ+cosφcosδ, sin ⁡ a min ⁡ = sin ⁡ φ sin ⁡ δ − cos ⁡ φ cos ⁡ δ \sin a_{\min}=\sin\varphi\sin\delta-\cos\varphi\cos\delta sinamin​=sinφsinδ−cosφcosδ。 a min ⁡ ≥ 0 a_{\min}\ge 0 amin​≥0 拱极, a max ⁡ ≤ 0 a_{\max}\le 0 amax​≤0 永不升起,否则有升有落。
  • 升出: cos ⁡ H = − tan ⁡ φ tan ⁡ δ \cos H=-\tan\varphi\tan\delta cosH=−tanφtanδ, cos ⁡ A = sin ⁡ δ / cos ⁡ φ \cos A=\sin\delta/\cos\varphi cosA=sinδ/cosφ。 ∣ cos ⁡ H ∣ > 1 |\cos H|>1 ∣cosH∣>1 或极地时返回空,不要 acos 出 NaN,不要除零。
  • 禁止 eval、new Function、innerHTML 拼接用户输入、远程脚本、getUserMedia、Geolocation、fetch 星表、CDN、Three.js。
  • node:test 必须覆盖: φ = 40 ∘ , δ = 89.3 ∘ \varphi=40^\circ,\delta=89.3^\circ φ=40∘,δ=89.3∘ 拱极; φ = 40 ∘ , δ = − 63.1 ∘ \varphi=40^\circ,\delta=-63.1^\circ φ=40∘,δ=−63.1∘ 永不升起; φ = 0 ∘ , δ = 0 ∘ \varphi=0^\circ,\delta=0^\circ φ=0∘,δ=0∘ 有升有落且 a max ⁡ ≈ 90 ∘ a_{\max}\approx 90^\circ amax​≈90∘;空纬度回退 39.9042;sanitizeLat(0)===0;XSS 含 <;极地不除零。
  • 空字符串纬度不得变成 0。0 是赤道的合法值。空字符串赤纬回退北极星 89.26,同样不得变成 0。
  • 做完用本地测试命令跑一遍,把结果贴回来;不要自动乱开新仓。
  • 禁止做成日晷、节气百科、蟋蟀气温、卫星过境、露点结霜、地平线视距。题目必须是拱极,不能把旧作换皮。

提示词里「物理与算法」那一段是原文贴进去的,不是让模型自己编公式。核心几行如下:

上中天(时角 H=0)高度:
sin a_max = sin φ · sin δ + cos φ · cos δ
a_max = asin( clamp(sin a_max, -1, 1) )

下中天(时角 H=180°)高度:
sin a_min = sin φ · sin δ − cos φ · cos δ

判定(高度以度比较,允许 ±1e-9 的浮点容差):
- a_min ≥ 0  → 拱极(永不落下)
- a_max ≤ 0  → 永不升起
- 否则        → 有升有落

合法零值:
- 纬度 0 是赤道,合法。
- 赤纬 0 是天赤道,合法。
空字符串纬度必须回退北京 39.9042,不得变成 0。
空字符串赤纬必须回退北极星 89.26,不得变成 0。

手算尺子也写进同一段: φ = 40 ∘ , δ = 89.3 ∘ \varphi=40^\circ,\delta=89.3^\circ φ=40∘,δ=89.3∘ 拱极且 a max ⁡ ≈ 40.7 ∘ a_{\max}\approx 40.7^\circ amax​≈40.7∘;Acrux 永不升起;天狼有升有落;赤道上看天赤道 a max ⁡ ≈ 90 ∘ a_{\max}\approx 90^\circ amax​≈90∘。测试必须对准这五组,模型不能自己换一组「看起来差不多」的数。

向码道提交拱极星实验室的完整需求,输入框下方绑定了仓库

图 11:仓库已经绑在输入框下方。长需求一次贴完,比拆成十句「再加点功能」更不容易跑偏。

码道先列分层目录,再写 constants.js / sky.js / sanitize.js,用 node --test 把核心层跑绿,然后才动 Canvas 和 index.html。

码道按分层写 domain、sanitize、sphere、HUD

图 12:目录和提示词逐条对齐。公式不准进点击事件,这一点必须在文件树里能看出来,不能只写在 README 的架构图里。对话框里出现过一次 Todo 未完成——验收不以对话框文案为准,以 git pull 之后的文件树和测试为准。远端随后推上了 838e22d,48 个用例全绿。

三、公式:asin、acos、以及不要除零

常数写在 src/domain/constants.js:

export const HEIGHT_TOLERANCE_DEG = 1e-9;
export const STATUS = Object.freeze({
  CIRCUMPOLAR: 'circumpolar',
  RISE_SET: 'rise-set',
  NEVER_RISE: 'never-rise'
});
export const FALLBACK_LAT = 39.9042; // 北京示意纬度
export const FALLBACK_DEC = 89.26;   // 北极星示意赤纬

asin / acos 都包一层。被夹住的是浮点噪声,不是物理——Math.asin(1.0000000002) 会直接抛 NaN,教学页上不能出现:

function clamp(value, lo, hi, fallback) {
  const n = Number(value);
  if (!Number.isFinite(n)) return fallback;
  if (n < lo) return lo;
  if (n > hi) return hi;
  return n;
}

function safeAsin(x) {
  return Math.asin(clamp(x, -1, 1, 0));
}

function safeAcos(x) {
  return Math.acos(clamp(x, -1, 1, 0));
}

上中天和下中天只差一个符号。 H = 0 H=0 H=0 时 cos ⁡ H = 1 \cos H=1 cosH=1, H = 180 ∘ H=180^\circ H=180∘ 时 cos ⁡ H = − 1 \cos H=-1 cosH=−1:

export function altitudeAtUpperCulmination(latDeg, decDeg) {
  const phi = clamp(latDeg, -90, 90, 0) * RAD;
  const dec = clamp(decDeg, -90, 90, 0) * RAD;
  const sinA = Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec);
  return radToDeg(safeAsin(sinA));
}

export function altitudeAtLowerCulmination(latDeg, decDeg) {
  const phi = clamp(latDeg, -90, 90, 0) * RAD;
  const dec = clamp(decDeg, -90, 90, 0) * RAD;
  const sinA = Math.sin(phi) * Math.sin(dec) - Math.cos(phi) * Math.cos(dec);
  return radToDeg(safeAsin(sinA));
}

测试里还对了一遍恒等式 sin ⁡ a max ⁡ = cos ⁡ ( φ − δ ) \sin a_{\max}=\cos(\varphi-\delta) sinamax​=cos(φ−δ)。几组随机角对不上,公式就写错了,后面的判定全是空中楼阁。

判定用度比较,留 10 − 9 10^{-9} 10−9 的容差,避免刚好压在地平上时抖动:

export function classify(latDeg, decDeg) {
  const aMax = altitudeAtUpperCulmination(latDeg, decDeg);
  const aMin = altitudeAtLowerCulmination(latDeg, decDeg);
  if (aMin >= -HEIGHT_TOLERANCE_DEG) return STATUS.CIRCUMPOLAR;
  if (aMax <= HEIGHT_TOLERANCE_DEG) return STATUS.NEVER_RISE;
  return STATUS.RISE_SET;
}

升出时角先挡住极点和天极: cos ⁡ φ \cos\varphi cosφ 或 cos ⁡ δ \cos\delta cosδ 过小,tan 会炸。 ∣ cos ⁡ H ∣ > 1 |\cos H|>1 ∣cosH∣>1 直接返回 null,和判定一致:

export function riseHourAngleHours(latDeg, decDeg) {
  const phi = clamp(latDeg, -90, 90, 0) * RAD;
  const dec = clamp(decDeg, -90, 90, 0) * RAD;
  if (Math.abs(Math.cos(phi)) < 1e-12 || Math.abs(Math.cos(dec)) < 1e-12) {
    return null;
  }
  const cosH = -Math.tan(phi) * Math.tan(dec);
  if (cosH < -1 || cosH > 1) return null;
  return safeAcos(cosH) * 12 / Math.PI;
}

升出方位同样保护除数。北极点 cos ⁡ φ = 0 \cos\varphi=0 cosφ=0,没有「从北向东」这回事:

export function riseAzimuthDeg(latDeg, decDeg) {
  const phi = clamp(latDeg, -90, 90, 0) * RAD;
  const dec = clamp(decDeg, -90, 90, 0) * RAD;
  const cosPhi = Math.cos(phi);
  if (Math.abs(cosPhi) < 1e-9) return null;
  const cosA = Math.sin(dec) / cosPhi;
  if (cosA < -1 || cosA > 1) return null;
  return radToDeg(safeAcos(cosA));
}

UI 不直接调这五个函数。computeSky 把它们聚合成一份不可变结果:无升落时 riseHourAngleHours / riseAzimuthDeg 明确是 null,不写 NaN,也不写 0——0 在方位里是正北,语义完全不同。

export function computeSky(latDeg, decDeg) {
  const phi = clamp(latDeg, -90, 90, 0);
  const dec = clamp(decDeg, -90, 90, 0);
  const aMax = altitudeAtUpperCulmination(phi, dec);
  const aMin = altitudeAtLowerCulmination(phi, dec);
  const status = classify(phi, dec);
  let riseHours = null;
  let riseAz = null;
  if (status === STATUS.RISE_SET) {
    riseHours = riseHourAngleHours(phi, dec);
    riseAz = riseAzimuthDeg(phi, dec);
  }
  return {
    latDeg: phi,
    decDeg: dec,
    altitudeUpper: aMax,
    altitudeLower: aMin,
    status,
    riseHourAngleHours: riseHours,
    riseAzimuthDeg: riseAz
  };
}

手算几组,和测试用例对得上:

$\varphi$$\delta$判定$a_{\max}$$a_{\min}$
$40^\circ$$89.3^\circ$拱极$\approx 40.7^\circ$$\gt 0$
$40^\circ$$-63.1^\circ$永不升起$\lt 0$—
$40^\circ$$-16.7^\circ$有升有落$\gt 0$$\lt 0$
$0^\circ$$0^\circ$有升有落$\approx 90^\circ$$\approx -90^\circ$
$90^\circ$$20^\circ$拱极$20^\circ$$20^\circ$

天球盘故意画子午圈侧视图,不是全天星图。圆是天球,水平直径是地平,北天极高度等于观测纬度。赤纬圈是周日路径在这个平面上的投影,用椭圆示意:长半轴 R cos ⁡ δ R\cos\delta Rcosδ,短半轴 R cos ⁡ δ ⋅ ∣ sin ⁡ φ ∣ R\cos\delta\cdot|\sin\varphi| Rcosδ⋅∣sinφ∣,绕北天极旋转 φ \varphi φ。极距接近 0 时退化成一个点,贴在北天极上——北极星就是这种情况。高度比例是教学夸张,页脚和 README 都写了。

四、架构:事件只做编排

分层按提示词落地,公式不准进点击事件:

src/domain/constants.js    状态枚举、回退默认值、免责文案
src/domain/sky.js          纯函数:a_max、a_min、classify、rise、azimuth
src/cities.js              31 个省级驻地示意坐标
src/stars.js               20 颗亮星示意赤经赤纬
src/security/sanitize.js   白名单 / clamp / 空字符串回退 / HTML 转义
src/services/storage.js    localStorage,失败静默
src/ui/sphere.js           Canvas 天球盘(子午圈侧视图)
src/ui/render.js           HUD(只用 textContent / createElement)
src/main.js                事件 → sanitize → domain → render
index.html                 松烟墨主题,内联 CSS,原生 ES Module
tests/                     node:test,零依赖

数据流是一条直线:

DOM 事件
  → sanitize(白名单 / clamp / 空字符串回退)
    → domain(altitudeAtUpperCulmination / classify / riseHourAngleHours)
      → render(HUD / 判定 / 对照)+ sphere(Canvas)
        → storage(写入前再 sanitize,失败静默)

main.js 里的 recompute() 只做编排。它先读当前城市或自定义纬度,再消毒,再算一组数,再交给 renderHud / renderSphere。点下拉框时不会出现一行 Math.asin。

function currentLat() {
  if (state.useCustomLat) return state.customLat;
  const c = findCityById(state.cityId);
  return c ? c.lat : 39.9042;
}

function recompute() {
  const lat = sanitizeLat(currentLat());
  const dec = sanitizeDec(currentDec());
  const result = computeSky(lat, dec);
  const city = findCityById(state.cityId);
  const star = findStarById(state.starId);

  renderHud(els, result);
  renderCompare(els, result.altitudeUpper, state.compareAlt);
  renderSphere(els.canvas, {
    size: 360,
    latDeg: lat,
    decDeg: dec,
    aMax: result.altitudeUpper,
    aMin: result.altitudeLower,
    status: result.status,
    starName: star ? star.name : '恒星',
    cityName: city ? city.name : ''
  });
  savePreferences({
    cityId: state.cityId,
    starId: state.starId,
    useCustomLat: state.useCustomLat,
    useCustomDec: state.useCustomDec,
    customLat: state.customLat,
    customDec: state.customDec
  });
}

键盘微调写在窗口上,但输入框聚焦时必须让路。否则人在数字框里按 ↑ 想把 39.9 改成 40.9,页面会先抢一步,体验直接崩:

window.addEventListener('keydown', (e) => {
  if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
  const ae = document.activeElement;
  if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'SELECT' || ae.tagName === 'TEXTAREA')) {
    return;
  }
  e.preventDefault();
  if (!state.useCustomLat) {
    state.useCustomLat = true;
    state.customLat = sanitizeLat(currentLat());
  }
  const step = e.shiftKey ? 1 : 0.1;
  const dir = e.key === 'ArrowUp' ? 1 : -1;
  state.customLat = sanitizeLat(state.customLat + dir * step);
  syncControlsFromState();
  recompute();
});

HUD 渲染全是 textContent。无升落时写「—(无升落)」,不写 NaN,也不写 0.00°:

export function renderHud(els, result) {
  if (!els || !result) return;
  const status = result.status || STATUS.RISE_SET;
  setText(els.status, STATUS_LABEL[status] || '');
  if (els.status) els.status.setAttribute('data-status', status);
  setText(els.aMax, formatDeg(result.altitudeUpper));
  setText(els.aMin, formatDeg(result.altitudeLower));
  if (result.riseHourAngleHours !== null && Number.isFinite(result.riseHourAngleHours)) {
    setText(els.riseHours, result.riseHourAngleHours.toFixed(3) + ' h(一侧,从子午到地平)');
  } else {
    setText(els.riseHours, '—(无升落)');
  }
  if (result.riseAzimuthDeg !== null && Number.isFinite(result.riseAzimuthDeg)) {
    setText(els.riseAz, result.riseAzimuthDeg.toFixed(2) + '°(从北向东)');
  } else {
    setText(els.riseAz, '—(极地或无升落)');
  }
  setText(els.latEcho, 'φ = ' + result.latDeg.toFixed(4) + '°');
  setText(els.decEcho, 'δ = ' + result.decDeg.toFixed(4) + '°');
}

城市和恒星下拉用 createElement('option'),名字走 textContent。预设数据本身是冻结数组,id 是白名单的源头:

export const CITIES = Object.freeze([
  { id: 'beijing', name: '北京', lat: 39.9042, lon: 116.4074 },
  { id: 'lhasa',   name: '拉萨', lat: 29.6500, lon:  91.1000 }
  // …去重后 31 个省级驻地
]);

export const STARS = Object.freeze([
  { id: 'polaris', name: '北极星',   raHours: 2.530, decDeg:  89.26, mag: 1.98 },
  { id: 'sirius',  name: '天狼',     raHours: 6.752, decDeg: -16.72, mag: -1.46 },
  { id: 'acrux',   name: '十字架二', raHours: 12.443, decDeg: -63.10, mag: 0.77 }
  // …共 20 颗亮星
]);

天球盘不读 DOM,只吃 computeSky 给的数。北天极画在高度 φ \varphi φ、方位北;赤纬圈绕它转;恒星点落在上中天。a_{\max}\le 0 时改成暗色,表示这颗星此刻在地下:

function drawDecCircle(ctx, ncp, R, latDeg, decDeg) {
  const phi = latDeg * RAD;
  const dec = decDeg * RAD;
  const cosDec = Math.cos(dec);
  const longAxis = Math.abs(R * cosDec);
  const shortAxis = Math.abs(R * cosDec * Math.sin(phi));
  if (longAxis < 0.5) {
    ctx.beginPath();
    ctx.arc(ncp.x, ncp.y, 2, 0, 2 * Math.PI);
    ctx.fill();
    return;
  }
  ctx.setLineDash([4, 3]);
  ctx.beginPath();
  ctx.ellipse(ncp.x, ncp.y, shortAxis, longAxis, phi, 0, 2 * Math.PI);
  ctx.stroke();
  ctx.setLineDash([]);
}

源码里搜不到真正执行的 innerHTML、eval、new Function、fetch(、getUserMedia、geolocation——那几个词只出现在注释里的禁令。

偏好存储失败必须静默。隐私模式、配额满、键被改成 "<script>",一律回退北京 + 北极星,不能弹 alert,也不能把页面打白。读写前后都过 sanitize:

export function loadPreferences() {
  return {
    cityId: sanitizeCityId(safeGet(KEYS.CITY_ID)),
    starId: sanitizeStarId(safeGet(KEYS.STAR_ID)),
    useCustomLat: sanitizeBoolean(safeGet(KEYS.USE_CUSTOM_LAT), false),
    useCustomDec: sanitizeBoolean(safeGet(KEYS.USE_CUSTOM_DEC), false),
    customLat: sanitizeLat(safeGet(KEYS.CUSTOM_LAT)),
    customDec: sanitizeDec(safeGet(KEYS.CUSTOM_DEC))
  };
}

export function savePreferences(prefs) {
  if (!prefs || typeof prefs !== 'object') return;
  if (prefs.cityId !== undefined) {
    safeSet(KEYS.CITY_ID, sanitizeCityId(prefs.cityId));
  }
  // …其余字段同样先 sanitize 再写
}

五、安全:空纬度、合法的 0、XSS,三条红线分开写

安全 20% 不是「加一个 CSP 就算」。这个项目里真正会出事的有三类输入:纬度、赤纬、城市 / 恒星 id。另外还有一类「看起来像功能、其实是权限」的诱惑:申请定位、联网拉星表。

1. 空字符串不能当成赤道

Number("") === 0 是 JavaScript 的经典坑。纬度 0 是合法的赤道,空输入必须回退北京 39.9042。赤纬同理:0 是天赤道,空输入回退北极星 89.26。提示词里点名了这条,生成代码也专门把 fallback 和 0 分开:

function isEmptyish(input) {
  if (input === null || input === undefined) return true;
  if (typeof input === 'string') return input.trim() === '';
  return false;
}

function sanitizeNumber(input, lo, hi, fallback) {
  if (isEmptyish(input)) return fallback;
  const n = Number(input);
  if (!Number.isFinite(n)) return fallback;
  if (n < lo) return lo;
  if (n > hi) return hi;
  return n;
}

export function sanitizeLat(input) {
  return sanitizeNumber(input, -90, 90, FALLBACK_LAT);
}
export function sanitizeDec(input) {
  return sanitizeNumber(input, -90, 90, FALLBACK_DEC);
}

测试名字就叫「空字符串纬度回退 39.9042,不得变 0」。sanitizeLat(0) 仍然是 0,证明赤道没有被误杀。

我在页面上勾上「自定义纬度」,把输入框清空再触发 input。输入框保持空,HUD 上的 φ \varphi φ 回到 39.9042°,判定仍是拱极,没有变成赤道。

清空纬度:输入框为空,HUD 回退北京 39.9042°,不是 0

图 13:空值和 0 必须能同时活在页面上。如果清空之后 HUD 变成 0.00 ∘ 0.00^\circ 0.00∘,评审会认为你把「赤道」这一类合法零值也误伤了。然后再把纬度填 0、赤纬填 0,上中天必须变成 90.00 ∘ 90.00^\circ 90.00∘——证明 0 没有被当成空值。

2. 范围夹紧,非法 id 回退白名单

φ , δ ∈ [ − 90 , 90 ] \varphi,\delta\in[-90,90] φ,δ∈[−90,90]。城市 id、恒星 id 走白名单,不在表里就回北京 / 北极星。localStorage 被手工改成 "<script>" 时,读回来直接回退,不会把 XSS 写进 <option>。

export function sanitizeCityId(input) {
  if (typeof input !== 'string') return DEFAULT_CITY_ID;
  const trimmed = input.trim();
  if (trimmed.length === 0) return DEFAULT_CITY_ID;
  if (CITIES.some(c => c.id === trimmed)) return trimmed;
  return DEFAULT_CITY_ID;
}

export function sanitizeStarId(input) {
  if (typeof input !== 'string') return DEFAULT_STAR_ID;
  const trimmed = input.trim();
  if (trimmed.length === 0) return DEFAULT_STAR_ID;
  if (STARS.some(s => s.id === trimmed)) return trimmed;
  return DEFAULT_STAR_ID;
}

对照高度和城市 id 不是一类东西。留空表示「我没填」,不是「对照 0°」。所以空串回 null,不是回 0:

export function sanitizeCompareAltitude(input) {
  if (isEmptyish(input)) return null;
  const n = Number(input);
  if (!Number.isFinite(n)) return null;
  if (n < -90) return -90;
  if (n > 90) return 90;
  return n;
}

数字框失焦后仍要再 sanitize 一遍,防止有人键入 99999。开关只认 true / false / "true" / "false",其它一律回默认。localStorage 里写 "yes" 不能把自定义纬度悄悄打开。

3. 转义之后,渲染仍然用 textContent

escapeHtml 转义 & < > " '。测试断言 '<script>' 转出来含 &lt;。HUD、城市名、对照差值全部 textContent。双重保险的原因很具体:以后如果有人改成 innerHTML「为了少写几个 createElement」,转义过的文本仍然是文本。

export function escapeHtml(input) {
  if (input === null || input === undefined) return '';
  return String(input)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

function createOption(value, label, selected) {
  const opt = document.createElement('option');
  opt.value = value;
  opt.textContent = label;
  if (selected) opt.selected = true;
  return opt;
}

对照差值也走同一条路。用户输入已经是 sanitizeCompareAltitude 之后的有限数字,渲染时再拼字符串,仍然用 textContent:

export function renderCompare(els, modelValue, userInput) {
  if (userInput === null || !Number.isFinite(userInput) ||
      modelValue === null || !Number.isFinite(modelValue)) {
    setText(els.compareDiff, '—');
    return;
  }
  const diff = userInput - modelValue;
  const sign = diff >= 0 ? '+' : '';
  setText(els.compareDiff,
    '目测 ' + userInput.toFixed(2) + '° − 模型 ' + modelValue.toFixed(2) +
    '° = ' + sign + diff.toFixed(2) + '°');
}

极地那条也算安全。 φ = ± 90 ∘ \varphi=\pm 90^\circ φ=±90∘ 时升出方位必须是 null,不能是 Infinity。暴力扫描 φ , δ \varphi,\delta φ,δ 网格,所有输出有限、不抛错。storage.js 在 Node 测试里没有 localStorage,getStorage() 返回 null,读写都不抛——这是隐私模式的同一条路径。

六、测试:48 项全绿之后,还要亲手换三颗星

tests/ 下面 5 个文件一共 48 个用例,零依赖,跑 npm test 即可:sky.test.js、sanitize.test.js、storage.test.js、cities-stars.test.js、chain.test.js。它们覆盖的不是「页面好不好看」,而是三条会让物理和安全同时崩掉的路径。

公式路径。 φ = 40 ∘ , δ = 89.3 ∘ \varphi=40^\circ,\delta=89.3^\circ φ=40∘,δ=89.3∘ 必须拱极, a max ⁡ a_{\max} amax​ 落在 40.7 ± 0.2 ∘ 40.7\pm 0.2^\circ 40.7±0.2∘。Acrux 在 40 ∘ 40^\circ 40∘ 必须永不升起。天狼必须有升有落。赤道上看天赤道, a max ⁡ = 90 ∘ a_{\max}=90^\circ amax​=90∘、 a min ⁡ = − 90 ∘ a_{\min}=-90^\circ amin​=−90∘。北极点北半天球都拱极。

test('φ=40°, δ=89.3°(北极星示意)→ 拱极,a_min>0,a_max≈40.7°', () => {
  const aMax = altitudeAtUpperCulmination(40, 89.3);
  const aMin = altitudeAtLowerCulmination(40, 89.3);
  assert.equal(classify(40, 89.3), STATUS.CIRCUMPOLAR);
  assert.ok(aMin > 0);
  assert.ok(Math.abs(aMax - 40.7) < 0.2);
});

test('φ=40°, δ=-63.1°(Acrux)→ 永不升起,a_max<0', () => {
  assert.equal(classify(40, -63.1), STATUS.NEVER_RISE);
  assert.ok(altitudeAtUpperCulmination(40, -63.1) < 0);
});

test('φ=0°, δ=0° → a_max≈90°, a_min≈-90°, rise-set', () => {
  assert.ok(Math.abs(altitudeAtUpperCulmination(0, 0) - 90) < 1e-6);
  assert.ok(Math.abs(altitudeAtLowerCulmination(0, 0) + 90) < 1e-6);
  assert.equal(classify(0, 0), STATUS.RISE_SET);
});

非法输入路径。 空纬度、空赤纬、坏 JSON 偏好、非法城市 id、XSS 字符串、 φ = 999 \varphi=999 φ=999,全部不能把 computeSky 打成 Infinity 或抛错。极地不除零。

test('空字符串纬度回退 39.9042,不得变 0', () => {
  assert.equal(sanitizeLat(''), 39.9042);
  assert.equal(sanitizeLat('   '), 39.9042);
  assert.equal(sanitizeLat(null), 39.9042);
});

test('sanitizeLat(0) === 0(0 是赤道合法值)', () => {
  assert.equal(sanitizeLat(0), 0);
  assert.equal(sanitizeLat('0'), 0);
});

test('XSS 转义含 &lt;', () => {
  const out = escapeHtml('<script>alert(1)</script>');
  assert.ok(out.includes('&lt;'));
  assert.ok(!out.includes('<script>'));
  assert.equal(escapeHtml('&'), '&amp;');
});

test('极地 φ=±90 不除零、不抛错、输出有限', () => {
  for (const phi of [90, -90]) {
    const r = computeSky(phi, 20);
    assert.ok(Number.isFinite(r.altitudeUpper));
    assert.equal(r.riseAzimuthDeg, null);
  }
});

数据路径。 31 个城市 id 唯一,20 颗星 id 唯一,北极星赤纬接近 89.26,Acrux 赤纬接近 − 63.1 ∘ -63.1^\circ −63.1∘。坐标只是示意,不是实时星历。chain.test.js 把城市 × 恒星全乘一遍,确认 sanitize → domain 全链不抛错。

storage.test.js 还注入了一个会抛错的 mock localStorage。读写都被 try/catch 吃掉,测试断言异常不会冒到调用方。Node 环境本来就没有 localStorage,这条路径和隐私模式是同一件事。

本地复跑约 0.3 秒、48/48。这个数字只能说明仓库可复现。我又用静态服务打开页面,按了五种操作:

  1. 默认首屏:北京 + 北极星,判定「永不落下」, a max ⁡ = 40.64 ∘ a_{\max}=40.64^\circ amax​=40.64∘。
  2. 天狼:判定「有升有落」,切片上赤纬圈穿过地平。
  3. 十字架二:判定「永不升起」, a max ⁡ = − 13.00 ∘ a_{\max}=-13.00^\circ amax​=−13.00∘。
  4. 清空纬度:HUD 回 39.9042,不是 0。
  5. φ = 0 , δ = 0 \varphi=0,\delta=0 φ=0,δ=0: a max ⁡ = 90.00 ∘ a_{\max}=90.00^\circ amax​=90.00∘,证明 0 合法。

第四、第五步是这条作品的安全题。Agent 报告「已完成」的时候,仍要自己把输入框清空看一眼,再填一个 0。

七、运行效果:免责声明摊在页脚

本地起静态服务后,浏览器标题是「拱极星实验室 · 球面天文实验台」。页脚把公式、判定、运行方式和免责声明摊开,不藏在 README 里。打开就能教,不必先翻仓库。

页脚:上中天 / 下中天公式、拱极判定、不能用于导航或望远镜指向

图 14:公式、MIT、不能用于导航 / 望远镜指向,都写在页面上。教学站如果把关键公式藏进源码注释,用户学不到,评审也看不到。

交互上有几处是提示词里点名、页面上能摸到的:

  • HUD 用等宽数字,判定带 aria-live="polite"。
  • 桌面左右分栏:左边参数,右边读数和天球盘;窄屏上下堆叠。
  • ↑ / ↓ 微调纬度 0.1°,Shift 步长 1°;焦点在 input / select 时不抢键。
  • 松烟墨底、绢白文字、朱砂强调「看不见」、石青强调「拱极」。
  • 31 个省级驻地和 20 颗亮星都写「示意」,不是「精测」。

远端仓库也对得上:公开、MIT、README、JavaScript + HTML、贡献者 weixin_52908342、最新提交 feat: 拱极星实验室球面天文教学实验台。

AtomGit 公开仓库页面,含 README、LICENSE、源码目录

图 15:参赛要求的「可运行源码 + README + 开源协议」都在这个页面上。README 里重复了公式、免责声明和 npm test,克隆下来的人不用先读这篇博文。两次提交也很清楚:空仓 → 功能。HEAD 838e22d。

八、怎么在本地跑,以及我希望你亲自点的三下

git clone https://atomgit.com/weixin_52908342/circumpolar-lab.git
cd circumpolar-lab
npm test          # 48 个 node:test,无需 npm install
npx serve .       # 或 python -m http.server 8766

现代浏览器也可以直接打开 index.html。如果 file:// 下 ES Module 被拦截,用上面的静态服务即可。仓库没有构建步骤,没有 node_modules 也能跑页面;测试只依赖 Node 自带的 node:test / node:assert。

建议自己点这三下:

  1. 看尺子。 自定义 φ = 40 \varphi=40 φ=40、 δ = 89.3 \delta=89.3 δ=89.3。应当拱极, a max ⁡ a_{\max} amax​ 接近 40.7 ∘ 40.7^\circ 40.7∘。数字不对,后面都不必看。
  2. 看南天。 城市停在北京,恒星换成十字架二。应当「永不升起」;再换成天狼,应当「有升有落」,升出时角大约 5 小时。
  3. 看失败路径。 勾上自定义纬度,把输入框清空。HUD 必须回到 39.9042,不能变成 0。然后再填 0、赤纬也填 0,上中天必须变成 90.00 ∘ 90.00^\circ 90.00∘——证明 0 没有被当成空值。

秋夜若是看见北极星,那是真实的大气和岁差。这个页面算的是球面天文:忽略大气折射、岁差、自行、地平线遮挡。两者对不上,不是 bug,是模型边界。页脚那句「不能用于导航或望远镜指向」就是为这件事写的。

九、这一次用码道,人定架构,AI 分块实现

  1. 建仓会话和开发会话分开。 ag repo create 会把上下文打满。仓库地址出来就绑定新对话,把长需求贴过去。问卷阶段不要夹带 200 行提示词。
  2. 模型可以换,分层和测试清单不要换。 这次用的是 GLM-5.2。需求写清楚之后,换哪个模型都按同一份目录和验收命令走。
  3. 先把 domain 测绿,再写 Canvas。 asin / acos / 除数保护在纯函数里一次测完,天球盘只消费已经算好的 a max ⁡ a_{\max} amax​、 a min ⁡ a_{\min} amin​、status。
  4. 对话框说「未完成」,以 git 和测试为准。 拉下来复跑 npm test,再亲手清空纬度、切三颗星。Agent 的「完成」不是验收。
  5. 禁令写进提示词,也写进测试。 「不要 fetch 星表」如果只出现在口头,模型仍可能去申请 Geolocation。把它写成测试:非法输入必须有限、XSS 必须含 &lt;、极地必须返回 null。
  6. 免责声明写成常量。 DISCLAIMER 在 constants.js 里只有一句,首屏、页脚、README 共用。教学近似、非星历、不能导航,三处文案必须一致,不能一处写「示意」、一处写成「实时」。
  7. 0 和空字符串要成对出现在测试名里。 只测 sanitizeLat('')===39.9042 不够,必须同时有 sanitizeLat(0)===0。缺一条,评审会怀疑你把赤道误杀了。
维度权重自检
架构30%domain / security / services / ui 分层;recompute() 只编排
代码30%原生 ES Module,零构建;48 个 node:test 对准手算尺子
安全20%空串 ≠ 0;白名单 id;textContent;无定位、无 fetch 星表
UI/UX15%松烟墨主题、大数字 HUD、aria-live、子午圈侧视图
博文5%文案置顶,账号入画,仓库可跑带协议

拱极公式本身只有一行正弦。难的是:0 和空字符串同时合法、极地不能除零、天球盘不能看起来像观星 App。把这三件事写进提示词,码道就能把实验室搭起来。剩下的,是你自己在秋夜抬头,看那颗围着天极转、还不落下的星。

Logo

华为AI开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐