北极星为什么不落:我用华为云码道把拱极判定做成了天球实验室
北极星为什么不落:我用华为云码道把拱极判定做成了天球实验室
一键开通华为云码道 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。我只负责把分层、公式和禁令写死,把仓库拉回本地点一遍。
- 仓库(公开):https://atomgit.com/weixin_52908342/circumpolar-lab
- 协议:MIT
- 运行:
npx serve .或python -m http.server(零构建、无运行时依赖) - 测试:
npm test→ 本地复跑 48/48 通过,HEAD838e22d
这不是观星软件,也不是望远镜指向器。页面上只有一件事:给定观测纬度
φ
\varphi
φ 和恒星赤纬
δ
\delta
δ,看这颗星会不会落。赤经、日期、岁差、大气折射全部砍掉。砍完之后,判定只剩两个数的符号。

作品介绍:松烟墨底上的子午圈
打开页面就能做这几件事:
- 选一座中国省级驻地(去重后 31 个示意坐标),默认北京。
- 选一颗亮星(20 颗,含北极星、织女、天狼、十字架二),或自己填 φ \varphi φ、 δ \delta δ。
- 立刻看到上中天高度 a max a_{\max} amax、下中天高度 a min a_{\min} amin、判定(拱极 / 有升有落 / 永不升起)。
- 若有升有落,额外给出升出时角 H r i s e H_{\mathrm{rise}} Hrise(小时)和升出方位 A r i s e A_{\mathrm{rise}} Arise(从北向东)。
- Canvas 画一张子午圈侧视图:圆是天球,水平直径是地平,上方是可见天空。赤纬圈画成朱砂虚线椭圆;拱极时整圈在地平上,永不升起时整圈在地平下。
- 可选填「我目测 / 查阅的上中天高度」,对照模型差值。
↑/↓微调纬度,输入框聚焦时不抢键。偏好写入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∘(从北向东,也就是东南偏东)。

图 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∘。整圈在地平下,判定「永不升起」。南十字在北半球中纬度看不见,不是星图没画,是球面几何不允许。

图 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 在这里是合法物理点,不是空值。

图 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。作品不绑定某一个模型名,绑定的是需求写得清不清楚。

图 5:码道 Agent 界面。截图里带有 AtomGit 账号。模型在输入框旁选 GLM-5.2。
问卷仍然克制:托管平台选 AtomGit,仓库类型选 Other 以便自定义名称,仓库名填 circumpolar-lab,可见性选公开,初始化 README / .gitignore / MIT LICENSE。名称必须先想好。star-map 太像观星软件,polaris 只覆盖一颗星,circumpolar-lab 能同时说出拱极和实验室这两个词。

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

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

图 8:自定义仓库名。my-project 这种占位符不能带进提交记录。
码道调用 ag CLI 建仓、写 README / .gitignore / MIT LICENSE、推到 main。这一步没有业务代码,只有空仓和基础文件。

图 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。

图 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°,判定仍是拱极,没有变成赤道。

图 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>' 转出来含 <。HUD、城市名、对照差值全部 textContent。双重保险的原因很具体:以后如果有人改成 innerHTML「为了少写几个 createElement」,转义过的文本仍然是文本。
export function escapeHtml(input) {
if (input === null || input === undefined) return '';
return String(input)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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 转义含 <', () => {
const out = escapeHtml('<script>alert(1)</script>');
assert.ok(out.includes('<'));
assert.ok(!out.includes('<script>'));
assert.equal(escapeHtml('&'), '&');
});
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。这个数字只能说明仓库可复现。我又用静态服务打开页面,按了五种操作:
- 默认首屏:北京 + 北极星,判定「永不落下」, a max = 40.64 ∘ a_{\max}=40.64^\circ amax=40.64∘。
- 天狼:判定「有升有落」,切片上赤纬圈穿过地平。
- 十字架二:判定「永不升起」, a max = − 13.00 ∘ a_{\max}=-13.00^\circ amax=−13.00∘。
- 清空纬度:HUD 回 39.9042,不是 0。
- φ = 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: 拱极星实验室球面天文教学实验台。

图 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。
建议自己点这三下:
- 看尺子。 自定义 φ = 40 \varphi=40 φ=40、 δ = 89.3 \delta=89.3 δ=89.3。应当拱极, a max a_{\max} amax 接近 40.7 ∘ 40.7^\circ 40.7∘。数字不对,后面都不必看。
- 看南天。 城市停在北京,恒星换成十字架二。应当「永不升起」;再换成天狼,应当「有升有落」,升出时角大约 5 小时。
- 看失败路径。 勾上自定义纬度,把输入框清空。HUD 必须回到 39.9042,不能变成 0。然后再填 0、赤纬也填 0,上中天必须变成 90.00 ∘ 90.00^\circ 90.00∘——证明 0 没有被当成空值。
秋夜若是看见北极星,那是真实的大气和岁差。这个页面算的是球面天文:忽略大气折射、岁差、自行、地平线遮挡。两者对不上,不是 bug,是模型边界。页脚那句「不能用于导航或望远镜指向」就是为这件事写的。
九、这一次用码道,人定架构,AI 分块实现
- 建仓会话和开发会话分开。
ag repo create会把上下文打满。仓库地址出来就绑定新对话,把长需求贴过去。问卷阶段不要夹带 200 行提示词。 - 模型可以换,分层和测试清单不要换。 这次用的是 GLM-5.2。需求写清楚之后,换哪个模型都按同一份目录和验收命令走。
- 先把 domain 测绿,再写 Canvas。
asin/acos/ 除数保护在纯函数里一次测完,天球盘只消费已经算好的 a max a_{\max} amax、 a min a_{\min} amin、status。 - 对话框说「未完成」,以 git 和测试为准。 拉下来复跑
npm test,再亲手清空纬度、切三颗星。Agent 的「完成」不是验收。 - 禁令写进提示词,也写进测试。 「不要 fetch 星表」如果只出现在口头,模型仍可能去申请 Geolocation。把它写成测试:非法输入必须有限、XSS 必须含
<、极地必须返回null。 - 免责声明写成常量。
DISCLAIMER在constants.js里只有一句,首屏、页脚、README 共用。教学近似、非星历、不能导航,三处文案必须一致,不能一处写「示意」、一处写成「实时」。 - 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/UX | 15% | 松烟墨主题、大数字 HUD、aria-live、子午圈侧视图 |
| 博文 | 5% | 文案置顶,账号入画,仓库可跑带协议 |
拱极公式本身只有一行正弦。难的是:0 和空字符串同时合法、极地不能除零、天球盘不能看起来像观星 App。把这三件事写进提示词,码道就能把实验室搭起来。剩下的,是你自己在秋夜抬头,看那颗围着天极转、还不落下的星。
更多推荐



所有评论(0)