docs(tasks): birth-sky cover — chart page, report cover, first-chart reveal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
This commit is contained in:
Jesse_Chen
2026-09-28 18:06:10 +08:00
co-authored by Claude Opus 5.5
parent 1d2af8ee18
commit aa33399ae5
3 changed files with 508 additions and 0 deletions
+1
View File
@@ -248,6 +248,7 @@
| `TASK-consult-first-frame-and-pacing-20260928.md` | `PROGRESS-consult-first-frame-and-pacing-20260928.md` | **普通对话发出即有反馈、正文按打字节奏放出**:客户端分类前静默(BUG-976 副作用)+ 服务端分类后才开流 → 发出后一两秒空白;服务端 160 字截留 + 客户端 settle 一帧全放 → 追问轮正文一下全出。D1 首帧「收到,正在看你的问题…」、D2 先建流、D3 每帧上限 + 限时收尾、D4 160 字阈值不动 | 已验收并部署 staging(真机欠) | 分支 `codex/consult-first-frame-and-pacing-20260928`(未推送);进度 `PROGRESS-consult-first-frame-and-pacing-20260928.md`;BUG-1074 / BUG-1075 |
| `TASK-chart-types-and-report-buttons-20260928.md` | `PROGRESS-chart-types-and-report-buttons-20260928.md`、`PROGRESS-ayanamsa-report-buttons-20260928.md` | **星盘盘型扩充 + 表格随盘换 + 七政隐藏 + 岁差 6 项 + 我的报告按钮配色**:分盘表一直显示 D1 数据(BUG-1076);盘型条 + 「星盘类型」面板共 23 种(加 月亮盘 / Bhava / 行运 / D40 / D45 / D60);Bhava 端点缺 MC 静默等宫(BUG-1077);报告深色主按钮黑字深红、禁用半透明(BUG-1078,只改报告范围) | 已验收、已部署 staging `6a4e312c`(真机欠) | `af27709d`…`4189750c`(子代理实现 + Claude 验收补丁);BUG-1076~1078;含迁移 `20260928010000_profile_ayanamsa_six` |
| `TASK-site-button-contrast-20260928.md` | `PROGRESS-site-button-contrast-20260928.md` | **全站实心按钮配色与禁用态统一**:层外 `button { color: inherit }` 压掉 Tailwind 文字色(61 个 `<Button>` 浅 3.33 / 深 2.57),`.button-primary` 30 个浅色 3.14,全部禁用态 45% 透明;产品选 A:全站用报告页配色(浅 4.85 / 深 5.77),推翻 DESIGN「已知缺口」;禁用改灰;删 BUG-1078 局部覆盖(BUG-1079) | 已验收、已部署 staging `dc640931`(真机欠) | `12aac87b` + `fa58df16`;BUG-1079 |
| `TASK-birth-sky-cover-20260928.md` | `PROGRESS-birth-sky-cover-20260928.md` | **「那一刻的天空」封面**:出生那一刻头顶的真实天空做成 3:4 封面(产品从 A 年轮 / B 一笔画 / C 天空三版样稿中选 C,样稿 `assets/birth-sky-cover-sample-20260928.html`);星盘页按钮 + 保存图片、报告封面(须对得上报告出生资料)、首次建盘揭幕;不印出生资料、句库只描述天空、无新依赖 | 待领取 | 新功能,不开 BUG |
| `TASK-cend-ui-claude-alignment-20260916.md` | `PROGRESS-cend-ui-r1/r2/r3-20260916.md` | **C 端界面向 claude.ai 产品界面对齐(三轮串行 R1→R2→R3,都动 `globals.css`,不得并行)**:根因是 `frontend/CLAUDE_DESIGN.md` 扒的是 **claude.com 营销官网**,它自己在 Known Gaps 里写明 claude.ai 产品界面不在范围内,而 `DESIGN.md:3` 把它当成了产品界面的实现契约。**R1**:`--font-display` 里 Tiempos Headline / StyreneB **从未加载**(无 `@font-face`、`public/` 无字体、`layout.tsx` 只 vendor 了 Inter),中文标题全站落到 **宋体 / SimSun**,波及 20 处含助手回答的 h2/h3(BUG-737);亮色强调色 `#85432f` 与暗色 `#d78064` 不同源,产品拍板亮色换 **Claude coral `#cc785c`**,**易漏点**是 `globals.css:16` 的 `--color-ring` 硬编码在 `@theme inline` 里不跟随 `:root`,另有第四个 `:root` 亮色块(`:4358`)必须同步(BUG-738);`.composer-footer` 常驻 44px + 顶栏 68px + `--composer-reserve` 148px,每屏固定吃掉 216px,模型选择器移进输入框内部、删掉底栏、顶栏收到 46px 并删「分析对象」副标题。**R2**:空状态是营销落地页(hero 卡 + 两张 132px 入口大卡 + 3 列 156px 主题卡),输入框被压在 **800px 以上**内容之下,重排成「问候 + 居中输入框 + 两枚入口 pill + 一排 chip」。**R3**:侧栏两个 `<details>` 拍平成一条「最近」、星盘的两个入口(侧栏分组 + 账户菜单)收敛到一处、删掉逐条助手头像。**决策记录 D3 推翻 DESIGN.md「报告强调色与应用同源」一句**(报告刻意保留深棕)。原型图 https://claude.ai/code/artifact/da275da6-2954-4f50-99aa-32bb8694d38b(三套画面 + 明暗,页面标题就是建议字体栈的实际渲染)。环境缺口:无登录态无 Chrome,四项真机观感留 `docs/testing/`。BUG 段 737–738 | 待领取 | — |
| `TASK-cend-surfaces-claude-alignment-20260916.md` | `PROGRESS-cend-shell-20260916.md`、`PROGRESS-cend-report-20260916.md`、`PROGRESS-cend-rectification-20260916.md`、`PROGRESS-cend-chart-eph-20260916.md` | **次级页面对齐(上一单的续篇,R4→R5/R6,R7、R8 可并行)**:星盘 `/chart`、星历 `/ephemeris`、报告 `/reports` **各是脱离 app 外壳的独立全屏页**,顶部只有一个「返回对话」链接、侧栏整个消失,且三家各写了一套一模一样的 `*-shell`/`*-topbar`/`*-hero` 骨架——与上一单 E5 同根因(营销站 band 结构被套到产品界面)。**R4** 抽只读导航外壳 `AppNavRail`(只用现成的 `GET /api/sessions` + `GET /api/account`,会话行走 `sessionHref` 跳 `/?c=<uuid>`;**刻意不带**重命名/删除/收藏/归档——那套连着 `Home()` 的乐观更新与回滚,搬过来会撞 useState 增长门禁)。**R5** 星盘五 tab 下划线化 + 参数合表 + 行星表横向滚动;星历日期导航改 `‹ 日期 ›`。**R6** 报告中心卡片网格改行式列表;阅读页加常驻目录。**R7** 生时校正把可信区间从盘面板标题行提成常驻条(窄屏 `.is-compact` 下盘面板是 overlay,现在默认看不到区间),五个 `technique-audit` 折叠块收成两段。**R8** 设置内容区收窄(880px 弹窗里表单铺了 690px)、套餐卡三修饰符收敛成两态。**已解锁**:原挡路的设置单已于 `111b4a84`(BUG-698)合入。**两条不得回退**:BUG-698 的 `@supports (height: 1dvh)` 写法(重复声明回退会被 Lightning CSS 折叠)、BUG-616/617 的报告盘面 grid 实现。默认不占 BUG 号 | **R4–R8 全部已实现并验收合入** | R4:抽出 `AppNavRail`(只读,两个 GET,零写操作)+ `SecondaryShell`,三个次级页并入 app 外壳并删掉各自的 shell/topbar/hero;四个路由渲染标记**完全不变**(`/` `/chart` `/ephemeris` 仍 Static);CSS gzip −0.25%。`/reports/[reportId]` 留给 R6 与目录一起做。两处自身健壮性问题被测试抓到:`usePathname()` 可为 null、`fetch` 可能不存在。差点弄丢 BUG-717 的 eyebrow 文案(已放回)。R8:表单分区收窄到 440px(列表分区不变)、套餐卡三修饰符收敛成互斥的 `is-current` / `is-recommended`,`--highlighted` 删除改为滚动定位;手机端 `order:-1` 改挂 `[data-plan-alias]`(版位不是状态)。测试 3350→3354(净增 4),失败清单与基线逐条一致;`/` 仍 Static;我的干净构建实测 CSS gzip −3 字节。**遗留待产品拍板**:`?plan=` 深链现在完全没有视觉指向,只有滚动位置。R6:报告中心卡片网格改行式列表、阅读页并入外壳并把目录挪到右侧常驻。**任务书 E10 过期**——目录在 `cfcd369d` 就已存在,本轮是挪位置定稿而非从零加。挂外壳带出一个真实打印风险已处理:`.chat-app`/`.chat-panel` 是 `height:100%;overflow:hidden`,裸 `window.print()` 会把九节报告裁成一页,阅读页因此多挂一条只在挂载期生效的 print 样式解锁外壳。「生成中的分节进度」做不了——`REPORT_LIST_COLUMNS` 不返回节数,按 VOICE.md 不许前端编。R7:区间常驻条与盘面折叠收敛。**任务书 E9 也不准确**——对话区顶部早有常驻条 `RectificationTimeline` 且窄屏可见,真正只在盘面标题行的是**代表分钟**;因此没另造第二条,在既有条上补齐代表分钟与已答题数(与盘面同一次 `workingRectificationTime()` 调用)。折叠块实际是 **8 个**不是 5 个。**触发让步顺序第 5 条**:收窄进度未做——服务端无该字段,且 `candidate_range` 会放宽(BUG-572),前端相减会把一次放宽报成收窄,已写进 `BLOCKED.md`。顺带修掉一个**静默失效的旧断言**(`slice(indexOf(A), indexOf(B))` 在 B 改名后变成几乎整份文件,四条 `doesNotMatch` 假通过)|
| — (产品口头拍板,无任务书) | `PROGRESS-chart-glyphs-20260918.md` | **星盘改用行星符号,度数进行星表(非 Bug,无 BUG 号)**:北印星盘(星盘页 / 校正右栏 / 我的报告三处共用 `VedicChartSvg`)宫内不再画「水 19°」「罗逆 29°」这类文本——一宫三颗星就叠三行、字号压到 8。改成九个彩色符号(☉☽☿♀♂♃♄☊☋),每行最多 3 个、逆行改符号下同色横线、第 1 宫加 `As`、盘下两行图例。颜色只认星不表吉凶,罗计共用中性灰,`--color-planet-*` 九个 token 在 `:root` 与两个深色块各一份;颜色挂 `is-*` 修饰类而非行内 `style`(报告标记合同禁 `style=`)。符号字体是 vendored 的 9 字形 Noto Sans Symbols 子集(OFL,1.5 KB),`--font-planet-glyphs`,♀♂ 带 U+FE0E 防 iOS 画成表情。星盘页行星表四列扩到八列(行星 / 黄道经度 / 速度 / 宫位 / 星宿 / 宿主 / pada / 状态),度数精确到分,首行是上升(`vedic.ascendant` 新字段)。数据来源:`toNorthIndianChart()` 加结构化 `occupantGlyphs`;校正与报告两条路线的模型只有引擎/golden 显示文本,按首字解析并有测试锁住。**星宿未译中文**——仓库无对照表,印度 27 宿与中国二十八宿不同源,不自造 | 已实现,待验收 | `codex/chart-glyphs-20260918`:tsc 0 / lint 0 error(118 warning 不变)/ npm test 3471→3484 条、36 红与基线 `84b293fb` 逐条相同、0 新红 / `○ /` Static / 首屏 JS gzip 1,450,774→1,455,017(+0.29%),CSS gzip +2.23%(构成见 PROGRESS §2)。真机走查欠(iOS 符号呈现、深浅两主题配色、375px 横滚、打印) |
+265
View File
@@ -0,0 +1,265 @@
# TASK · 「那一刻的天空」封面:星盘页入口、报告封面、首次建盘揭幕(2026-09-28)
## 基线
- `origin/staging` = `dc640931`(2026-09-28)。开工时 `git fetch origin --prune`,以最新 `origin/staging` 为基线,实际 SHA 写进 PROGRESS。
- 分支 `codex/birth-sky-cover-20260928`,工作树 `.worktrees/birth-sky-cover-20260928`。
- 进度记录 `docs/tasks/PROGRESS-birth-sky-cover-20260928.md`。
- 串行关系:`TASK-site-button-contrast-20260928`(BUG-1079)改全站按钮配色。它若已合入 staging,就以它为基线;若还没合入,本单新增的按钮一律用现有共享按钮类(`.button-secondary` / `<Button variant="outline">`),不自己写按钮颜色,这样两单没有文件交集。同日其它任务书与本单无交集。
- 视觉样稿:`docs/tasks/assets/birth-sky-cover-sample-20260928.html`,看第三张「C · 那一刻的天空」。A、B 两张是被否掉的方案,只作对照,不实现。
## 这是什么(产品背景,不是事故)
这是新功能,没有事故实证。产品看了竞品的「人设标签 + 几何徽章」分享海报,要求**不照抄**,做一张符合本站气质(私人阅览室、安静、诚实、不给人贴标签)的封面。三版样稿里产品选了 C:
> 出生地、出生那一刻头顶的真实天空。天顶在圆心、东在左,地平线以上的行星与亮星照实画,下方一句话。不讲运势,不贴标签。
## 现状实证(Claude 2026-09-28 读码,行号按符号定位)
| 事实 | 位置 |
| --- | --- |
| 星盘页标题栏已有 `actions?: ReactNode` 插槽,星盘页目前没传 | `SecondaryPageShell`(`frontend/src/components/secondary-page-shell.tsx`)→ `SecondaryHeader`;调用方 `ChartPageView` |
| 星盘页数据里有 `date`、`time`(本地 HH:MM)、`timezoneOffset`、`latitude`、`longitude`、`birthTimeStatus`,**没有 UTC 瞬间 / JD** | `chartViewProfileSchema`(`frontend/src/lib/chart-view-contract.ts`) |
| 看谁的盘由 `useCurrentSubject` 决定(localStorage,不在 URL);BFF 用 `?subject=` + `loadSubjectBirth` 在服务端取出生资料 | `lib/current-subject.ts`、`lib/subject-birth.ts`、`app/api/chart-view/route.ts` |
| 服务端「是否采用了校正时间」只在 `resolveServerOwnedChartBirth` 的 `adoption` 里,不下发客户端 | `lib/chart-birth-truth.ts` |
| Python 侧**没有任何** `azalt` / 赤经赤纬 / 恒星表代码,仓库里也没有 `sefstars.txt` | `scripts/` 全量 grep |
| 报告页没有封面:`SecondaryHeader` → `ReportActions` → 正文;`ReportEnvelopeView` 不带出生资料,DB 行有 `chart_profile_id`,但 `reportView` 没暴露 | `PersonalReportPage`、`app/(app)/reports/[reportId]/page.tsx`、`lib/personal-report-route-core.ts` |
| 首次建盘在首页 onboarding 里完成:`saveOnboardingPlace` 保存后留在 `/`,不跳转 | `hooks/use-profile-onboarding.ts`、`app/(app)/page.tsx` |
| 前端没有任何 SVG / DOM 转 PNG 的代码;唯一下载模式是 Blob + `a.download` | `lib/consultation-report-export.ts` `downloadMarkdownReport` |
| 可复用的弹层:原生 `<dialog>`,窄屏是底部抽屉,宽屏居中 | `ReportExportDrawer`、`ChartTypePicker`;DESIGN.md「报告导出抽屉」一节 |
| 行数余量:`page.tsx` 1370 / 上限 1567;`jyotish_api_server.py` 10935 / 上限 11224 | `home-shell-growth-contract.test.ts`、`tests/test_api_server_growth_contract.py` |
## 决策记录(产品 2026-09-28 授权)
1. **画面选 C**(真实天空),A 年轮、B 一笔画都不做。
2. **三个位置都做**,按顺序:① 星盘页入口 + 预览 + 保存图片;② 个人报告封面;③ 自己第一次建完盘时揭幕一次。
3. **不在图上印任何出生资料**:不印姓名、日期、时刻、经纬度、地名。天空本身会透露大致时段,产品确认可以接受。
4. **图上的那句话只描述天空,不讲运势**:不写大运、上升、性格。句子从下文「句库」按天空状态确定性地选出,同一张盘永远同一句,不调模型、不扣点。
5. **出生时间未采用校正的也能生成**:`birthTimeStatus` 不是 `accepted` / `confirmed` 时,图底加一行小字「按你填写的时间」。
6. **不另开页面,不加侧栏入口,不做第六个 Tab**;星盘页只在标题栏右侧加一个按钮。
7. **中文不用衬线字体**:样稿里用了 Noto Serif SC,但 DESIGN.md §3(BUG-737)规定「CJK never takes a serif here」。本单按站内字体栈与字重 500 实现,**不引入新字体**。产品若想给封面单独开衬线例外,另立单。
8. **第一期不接社交平台分享 SDK**。保存图片走浏览器原生能力(见 T4)。
9. 罗睺、计都不是可见天体,**不画**。月亮在地平线下时不画,只进句库的事实句。
## 硬红线
1. `scripts/jyotish_api_server.py` 只做薄注册:`JyotishAPIHandler` 类方法数不得增长、`__new__` 伪造点不得增长;计算放新模块 `scripts/birth_sky.py`(AGENTS §6,由 `tests/test_api_server_growth_contract.py` 执行)。
2. `frontend/src/app/(app)/page.tsx`:`Home()` 的 `useState` / `useRef` 数不得增长;揭幕逻辑进 `hooks/` 或组件,由它们持有自己的状态,`page.tsx` 只接线(AGENTS §6,由 `home-shell-growth-contract.test.ts` 执行)。
3. **不新增 npm 依赖**。PNG 用原生 Canvas 2D 导出。
4. **封面代码全部懒加载**(`next/dynamic` 或动态 `import()`):`next build` 后 `/` 仍是 `○ Static`,首屏 gzip ±2%。
5. 出生资料只在服务端取(`loadSubjectBirth` / `resolveServerOwnedChartBirth`),**不接受客户端传来的日期、经纬度**,防止借接口给任意时刻地点算图。
6. 等待遵守 DESIGN §9:不写新 spinner 或骨架。星盘页进入后预取天空数据,点按钮时图已经就绪(见 T3);首次揭幕拿不到数据就静默跳过,不等待(见 T6)。
7. 恒星表只用公有领域来源(Yale Bright Star Catalogue 5th ed.,HR 编号),来源与许可写进数据文件头和 PROGRESS。不得从有版权的星图软件抓数据。
8. 不改数据库结构。一次性揭幕不需要「看过」标记(见 T6)。
9. 测试 fixture 来自真实引擎输出(golden),不手造形状(§7.4)。
10. 隐私:测试、PROGRESS、CHANGELOG 只用样稿里的虚构资料(1994-05-18 07:40 UTC+8,30.27N 120.15E)或公开名人资料。
## 任务分解
### T1 · 天空计算模块(Python)
新模块 `scripts/birth_sky.py`,提供纯函数 `compute_birth_sky(date, time, tz_offset_hours, lat, lon) -> dict`,在 `jyotish_api_server.py` 里薄注册为 `POST /api/birth_sky`。
- 瞬间:由本地日期时刻与 `tz_offset` 算 UT 与 JD。
- 行星:太阳、月亮、水、金、火、木、土的**真实高度角与方位角**(`swe.calc_ut` + `swe.azalt`,取几何高度 true altitude,不加大气折射;golden 按此口径)。方位角统一成「自正北起、向东为正」,**注意 swisseph 的方位角自正南起算**,要换算并在注释里写明。
- 恒星:`scripts/data/bright_stars.json` 收录 V 星等 ≤ 4.0 的恒星(HR 编号、J2000 赤经赤纬、星等),按 IAU 1976 岁差换到出生历元,再换算高度方位。只返回地平线以上的星。另附三组连线(北斗七星、仙后座 W、猎户座),用 HR 编号表示。
- 状态:`sunAltitude`,以及 `phase` ∈ `day`(太阳高度 > 0)/ `twilight_morning` / `twilight_evening`(−18° 到 0°,按上午或下午区分)/ `night`。
- 返回里**不带**输入的日期、时刻、经纬度。
**验收**
- `tests/test_birth_sky.py`:
1. 样稿虚构资料的 golden:太阳高度 31.7°±0.2°、方位 84.7°±0.3°(自北),土星高度 50.3°±0.2°、方位 191.1°±0.3°,月亮与木星在地平线下。
2. 独立校验:任意日期当地真太阳时正午,太阳高度 ≈ 90° − |纬度 − 赤纬|,误差 ±0.5°。
3. 北极星在北纬 30.27° 的高度约 30°(±1°)。
4. `phase` 四种状态各有一个用例。
- `tests/test_api_server_growth_contract.py` 通过。
- 快速门 `run_quality_gate.py --profile quick` 通过。
### T2 · BFF 路由与合同(前端服务端)
`frontend/src/app/api/birth-sky/route.ts`:`GET ?subject=<id>`。
- 服务端用与星盘页相同的方式解析这个 subject 的出生资料和 `adoption`。
- 调用 `/api/birth_sky`,返回 zod 合同 `birthSkyResponseSchema`(放在 `lib/birth-sky/contract.ts`):`phase`、`sunAltitude`、`planets[]`、`stars[]`、`lines[]`、`timeBasis: "adopted" | "reported"`。
- 资料不全时返回 `status: "birth_profile_incomplete"`,前端据此不显示入口。
- 不计费、不写库。
- 响应里**不带**出生资料原文。
**验收**
- `frontend/tests/birth-sky-route.test.ts`:
- 未登录返回 401;
- 别人的 subject 返回 404;
- 资料不全返回 incomplete;
- 正常返回符合合同,且序列化后的 JSON 不含 `latitude` / `longitude` / `date` / `time` / 地名字段。
### T3 · 封面渲染与星盘页入口(第一期)
- `lib/birth-sky/draw.ts` 提供一个**纯函数** `drawBirthSky(ctx, data, { palette, width, height, footnote })`,用 Canvas 2D 画整张 3:4 封面。预览和导出**共用这一个函数**,避免 SVG 转图片时网页字体丢失。
- 投影:天顶在圆心,r = (90 − 高度)/90 × R;北在上、**东在左**。
- 画面元素:
- 地平线圆,30° / 60° 虚线高度圈;
- 东南西北四个方位字;
- 恒星点,大小按星等;
- 三组连线;
- 行星用小圆点加中文名,太阳用赭色空心圆加光晕。
- 文字排版对照样稿 C:
- 顶部小字 `JYOTISHA · 那 一 刻`;
- 句库主句;
- 底部事实句;
- 最底 `仅供自我探索,不替你做决定。`;
- 需要时加「按你填写的时间」。
- 两套 palette:
- `night`:深色卡纸,屏幕用;
- `paper`:纸色底、墨色星点,报告打印用。
- 颜色取自 DESIGN.md 现有 token,不新增品牌色。
- `lib/birth-sky/sentence.ts`:句库选择,纯函数,见下表。**句子只能从这张表里出**,执行方不得自己加。
| 条件 | 主句 |
| --- | --- |
| `day` | 星星都在,只是还看不见。 |
| `twilight_morning` | 天快亮了,星星正一颗颗退场。 |
| `twilight_evening` | 天刚暗下来,星星正一颗颗出来。 |
| `night` | 那一刻,头顶就是这片天。 |
事实句最多两段,用「,」连接:
- 月亮:在地平线上时写「月亮在{八方位}」,在地平线下时写「月亮在脚下」。
- 除太阳外最高的一颗可见行星:写「{行星}正悬在{八方位}」。
- 八方位是:北、东北、东、东南、南、西南、西、西北。
- 白天另在事实句前加一段「太阳在{八方位}」。
- 按这些规则,样稿 C 那一刻应得「太阳在东,月亮在脚下,火星正悬在东南」。
VOICE.md 要求的开场问候不用「星星、月亮」一类比喻,这一条只管首页问候。封面写的是真实天体,不是比喻,所以不冲突。这四句定稿前请执行方对照 VOICE 的其余条款(短句、无 emoji),有冲突写进 PROGRESS,**不要自行改句**,由 Claude 验收时定。
- 组件 `components/birth-sky/birth-sky-dialog.tsx`:
- 复用 `ReportExportDrawer` 的原生 `<dialog>` 框架;窄屏占满,宽屏居中。
- 内容是一张按 3:4 等比缩放的 `<img>`(src 是导出的 PNG blob URL,手机上可长按保存),下方一个「保存图片」按钮。
- `ChartPageView` 往 `SecondaryPageShell` 的 `actions` 传一个按钮,文案「那一刻的天空」。
- 只在 `view.status === "ok"` 且天空数据就绪时出现。
- 星盘页 ok 后**立即预取** `/api/birth-sky`,完成后才显示按钮。预取失败就不显示按钮,不报错,也不占位。
- 按钮和弹层代码都懒加载。
- 切换人物(`useCurrentSubject` 变化)时按新 subject 重新预取。给家人的盘也能生成。
**验收**
- `frontend/tests/birth-sky-sentence.test.ts`:四种 phase 各一例,加事实句组合;样稿资料必须得到上面那句。
- `frontend/tests/birth-sky-draw.test.ts`:
- 用 mock canvas 记录 `fillText` 调用,断言画出的全部文字**不含**日期、时刻、经纬度、地名、姓名;
- 断言 `timeBasis: "reported"` 时出现「按你填写的时间」,`adopted` 时不出现;
- 断言东在左(方位 90° 的点 x < 圆心)。
- `frontend/tests/chart-page-view.test.tsx` 追加:数据就绪才出现按钮;资料不全或预取失败时无按钮。
- 导出尺寸是 1080×1440。
### T4 · 保存图片
- `lib/birth-sky/export.ts`:`canvas.toBlob("image/png")`,文件名 `jyotisha-sky.png`,不带人名和日期。
- 点「保存图片」:
- 若 `navigator.canShare?.({ files: [file] })` 为真,调 `navigator.share({ files })`,iOS 上会出现系统的「存储图像」;
- 否则走 Blob + `a.download`,与 `downloadMarkdownReport` 同一模式。
- 用户取消分享不算错误,不弹提示。
- 手机上长按预览图也能直接保存,这是 `<img>` 的原生行为。
**验收**:单测覆盖 share 可用、share 不可用、用户取消三条分支。真机项写进 `docs/testing/`(见 T7)。
### T5 · 报告封面(第二期)
- `reportView` 暴露报告对应的 subject(`chart_profile_id`,null 表示本人,对外映射成 `"self"`)。
- 报告页 ready 态在正文最上方加封面块:
- 屏幕上用 `night` palette,宽度随正文列、3:4 比例;
- 封面下方不加按钮,保存入口只在星盘页。
- **封面必须对得上报告**:只有当报告生成时的出生资料与现在的一致时才显示。执行方先查报告行或 `result_binding` 里是否存了出生资料的指纹:
- 有指纹:拿它和当前 `profileFingerprint` 比较,不一致就不显示封面;
- 找不到可靠的绑定:本单**不显示**封面,把缺口写进 PROGRESS 和 `BLOCKED.md`,**不要**拿当前资料冒充当时资料。
- 旧报告不补算、不补写库。没有封面时不留占位。
- 打印 / 保存 PDF:
- 封面改用 `paper` palette(DESIGN 规定打印固定浅色);
- 封面独占 A4 第一页(`break-after: page`);
- 不新增打印入口。
- 封面的取数在报告正文出现后进行,不阻塞正文。取数失败就不显示封面。
**验收**
- `frontend/tests/personal-report-view.test.ts` 追加:
- 指纹一致时有封面;
- 不一致或无绑定时无封面;
- 取数失败时无封面、正文照常。
- 打印样式的快照断言 `paper` palette 与分页。
### T6 · 首次建盘揭幕(第三期)
- 触发点:首页 onboarding 里**自己的**出生地保存成功(`saveOnboardingPlace` 成功之后)。它按定义每个账户只发生一次,所以**不需要「看过」标记**。
- 人物档案里新建家人不触发。
- 修改资料不触发。
- 保存成功后立即请求 `/api/birth-sky?subject=self`:
- **3 秒内**拿到数据,就在当前流程之上淡入同一个弹层。淡入 600ms,`prefers-reduced-motion` 时直接出现。
- 超时或失败就静默跳过,onboarding 照常往下走,不显示错误,不重试。
- 弹层内容与星盘页一致,另加一个「继续」按钮。
- 点「继续」、Esc、点遮罩都关闭弹层,回到原本的下一步(问候或校正引导),不改变下一步是什么。
- 若下一步会自动进入出生时间校正,弹层关闭后再进入。
- 逻辑放在新 hook `hooks/use-birth-sky-reveal.ts`(或组件内),`page.tsx` 只接线。
- 这一次揭幕属于用户操作后的结果展示,不是首页加载等待,不违反 DESIGN §9 的「首页只揭幕一次」。首页加载揭幕的轨道环逻辑**不动**。
**验收**
- `frontend/tests/profile-onboarding*.test.ts` 追加:
- 保存成功且 3 秒内有数据时出现弹层,关闭后进入原下一步;
- 超时时无弹层,流程照常;
- 家人档案不触发。
- `home-shell-growth-contract.test.ts` 通过。
- `next build` 后 `/` 仍是 `○ Static`,首屏 gzip ±2%。
### T7 · 文档
- `frontend/DESIGN.md`:新增一节「那一刻的天空」,写明:
- 投影与方位约定、两套 palette、三个位置;
- 预取规则、揭幕时限;
- 不印出生资料;
- 中文不用衬线。
- `frontend/docs/VOICE.md`:收录句库四句与事实句规则,并注明「只描述天空,不讲运势」。
- `CHANGELOG.md`:一条,写明 Skill 版本不 bump。
- `CONTEXT.md`:若新增「那一刻的天空 / 天空封面」术语,先进 glossary。
- `docs/testing/birth-sky-cover-checklist.md` 真机清单:
- iPhone Safari 预览图长按保存、「保存图片」弹系统分享;
- 安卓 Chrome 下载;
- 桌面 Chrome / Safari 下载;
- 家人档案生成;
- 报告封面屏幕显示与打印 PDF 第一页;
- 新账号首次建盘揭幕与跳过(可断网模拟)。
## 让步顺序(时间不够时从后往前砍)
1. 先砍 T6 首次揭幕。
2. 再砍 T5 报告封面。
3. T1~T4 + T7 是第一期,**不可砍**。
4. 猎户座、仙后座连线可以砍,只留北斗。
砍掉的部分写进 PROGRESS,不得标成完成。
## 开工前置命令
```bash
cd /workspace/Jyotisha && git status -sb | head -1
git fetch origin --prune
git worktree add -b codex/birth-sky-cover-20260928 .worktrees/birth-sky-cover-20260928 origin/staging
cd .worktrees/birth-sky-cover-20260928
grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1 # 开工时最大号
cd frontend && npm ci && ./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -5 # 记下测试总数基线
```
Node 用 22(本机在 `/exec-daemon/node`)。
## BUG 编号
这是新功能,本身不开 BUG 条目。实现中若发现既有缺陷,从开工时 `docs/BUG_HISTORY.md` 最大号 +1 起编,写作时最大号是 **BUG-1079**。
## 交付
- 三期可以在同一分支分三个提交,顺序 T1–T4+T7 → T5 → T6。每个提交独立可测。
- 推 staging 前把 PROGRESS 写全:
- 基线 SHA;
- tsc / lint / 测试数(总数不低于基线);
- Python 定向测试与快速门结果;
- `/` Static 与 gzip 数字;
- 砍掉的项;
- 环境缺口。
- 用 `git push origin HEAD:staging` 快进推送,核对远端 SHA 与 `/api/health` 的 `deployment.gitCommit`。
@@ -0,0 +1,242 @@
<title>个人封面样稿</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600&display=swap">
<style>
:root{
--ground:#f3f2ee; --surface:#fbfaf7; --ink:#1d1d1f; --ink-2:#5b5750; --ink-3:#8c877e;
--line:#dedad2; --accent:#8a5a2b; --accent-soft:#efe6da;
--serif:"Noto Serif SC","Songti SC","STSong",serif;
--sans:-apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--ground:#1a1917; --surface:#22211e; --ink:#ece9e2; --ink-2:#b7b1a6; --ink-3:#88837a;
--line:#35332f; --accent:#d2a06b; --accent-soft:#2e2923; color-scheme:dark;
}
}
:root[data-theme="dark"]{
--ground:#1a1917; --surface:#22211e; --ink:#ece9e2; --ink-2:#b7b1a6; --ink-3:#88837a;
--line:#35332f; --accent:#d2a06b; --accent-soft:#2e2923; color-scheme:dark;
}
*{box-sizing:border-box}
body{background:var(--ground);color:var(--ink);font-family:var(--sans);font-size:15px;line-height:1.65;padding:0 20px}
.wrap{max-width:1180px;margin:0 auto;padding-block:48px 72px}
header{max-width:680px}
h1{font-family:var(--serif);font-weight:600;font-size:clamp(26px,4vw,34px);line-height:1.3;margin:0 0 12px;text-wrap:balance}
.lede{color:var(--ink-2);margin:0}
.facts{display:flex;flex-wrap:wrap;gap:8px 28px;margin:28px 0 40px;padding:14px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);font-size:13px;color:var(--ink-2)}
.facts b{color:var(--ink);font-weight:600;margin-left:6px;font-variant-numeric:tabular-nums}
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:36px 28px}
@media (max-width:940px){.grid{grid-template-columns:1fr;max-width:460px;margin:0 auto}}
figure{margin:0;display:flex;flex-direction:column;gap:18px}
.poster{width:100%;aspect-ratio:3/4;max-width:100%;display:block;border-radius:4px;box-shadow:0 1px 2px rgba(0,0,0,.06),0 12px 32px -12px rgba(40,30,20,.22)}
.cap h2{font-family:var(--serif);font-size:19px;font-weight:600;margin:0 0 4px;display:flex;align-items:center;gap:10px}
.pick{font-family:var(--sans);font-size:12px;font-weight:500;color:var(--accent);background:var(--accent-soft);padding:1px 9px;border-radius:99px;letter-spacing:.04em}
.cap p{margin:0 0 10px;color:var(--ink-2);font-size:14px}
.cap dl{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0;font-size:13.5px}
.cap dt{color:var(--ink-3)}
.cap dd{margin:0;color:var(--ink-2)}
.scrub{display:flex;align-items:center;gap:12px;font-size:13px;color:var(--ink-2)}
.scrub input{flex:1;accent-color:var(--accent)}
.scrub output{font-variant-numeric:tabular-nums;min-width:4.5em;text-align:right;color:var(--ink)}
footer{margin-top:56px;color:var(--ink-3);font-size:13px;max-width:680px}
</style>
<div class="wrap">
<header>
<h1>一张盘的封面,三种画法</h1>
<p class="lede">下面三张都是用同一张<strong>虚构</strong>星盘真实算出来的(瑞士星历表,Lahiri 岁差,整宫制),没有一笔是装饰。今天按 2026 年 9 月算“此刻”。</p>
</header>
<div class="facts" aria-label="样稿所用数据">
<span>上升<b>双子 10°58′</b></span>
<span>月亮<b>巨蟹 26°12′(也是 AK)</b></span>
<span>当前大运<b>太阳 2026.03–2032.03</b></span>
<span>分运<b>月亮 至 2026.12</b></span>
</div>
<div class="grid">
<figure>
<svg class="poster" id="pA" viewBox="0 0 600 800" role="img" aria-label="年轮封面"></svg>
<div class="scrub">
<label for="year">拖动看“你在这里”怎么走</label>
<input id="year" type="range" min="1995" max="2113" step="0.25" value="2026.75">
<output id="yearOut">2026</output>
</div>
<div class="cap">
<h2>A · 年轮 <span class="pick">推荐</span></h2>
<p>一生 120 年的大运画成树的横截面。一圈是一段大运,宽窄等于年数,纹理随主星不同。走过的圈是墨色,没走的圈是浅色,赭色小点是“此刻”。</p>
<dl>
<dt>用到</dt><dd>大运起止、当前分运、上升度数(小点所在的那条线)</dd>
<dt>好在</dt><dd>讲时间不讲类型;换大运时画面真的会变,有理由回来看</dd>
<dt>顾虑</dt><dd>第一眼要读出“这是年轮”,需要底下那行小字帮忙</dd>
</dl>
</div>
</figure>
<figure>
<svg class="poster" id="pB" viewBox="0 0 600 800" role="img" aria-label="一笔画封面"></svg>
<div class="cap">
<h2>B · 一笔画</h2>
<p>九颗星按出生时的恒星黄道经度落在圆上,上升在左侧,按“日月火水木金土罗计”的顺序用一根手绘线连起来。</p>
<dl>
<dt>用到</dt><dd>九星经度、上升位置、上升与月亮星座(那一句话)</dd>
<dt>好在</dt><dd>最像一幅画;每个人的形状都不同,也都说得出来历</dd>
<dt>顾虑</dt><dd>不懂的人看不出和自己的关系;那句话容易滑向“贴标签”</dd>
</dl>
</div>
</figure>
<figure>
<svg class="poster" id="pC" viewBox="0 0 600 800" role="img" aria-label="出生时刻天空封面"></svg>
<div class="cap">
<h2>C · 那一刻的天空</h2>
<p>出生地、出生那一刻头顶的真实天空,天顶在圆心,东在左。地平线以上的行星点亮,亮星与北斗、仙后照实画。</p>
<dl>
<dt>用到</dt><dd>行星与亮星的真实高度、方位(需要出生地经纬度)</dd>
<dt>好在</dt><dd>最诚实、最浪漫,不做任何判断</dd>
<dt>顾虑</dt><dd>市面已有“出生星空图”;画面本身就泄露出生时间段与地点</dd>
</dl>
</div>
</figure>
</div>
<footer>样稿说明:星盘资料为虚构(1994 年 5 月,杭州),仅用于演示画法;文字是为样稿临时写的,正式版要对照文案口径再定。</footer>
</div>
<script>
const NS="http://www.w3.org/2000/svg";
const C={paper:"#f7f4ee",ink:"#2b2723",ink2:"#6e675d",faint:"#c9bda9",ochre:"#b0763a",night:"#1f1d1b",cream:"#efe7d8"};
function rng(seed){return function(){seed|=0;seed=seed+0x6D2B79F5|0;let t=Math.imul(seed^seed>>>15,1|seed);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
function el(tag,attrs,parent){const e=document.createElementNS(NS,tag);for(const k in attrs)e.setAttribute(k,attrs[k]);if(parent)parent.appendChild(e);return e}
function txt(parent,x,y,s,o={}){const t=el("text",Object.assign({x,y,fill:C.ink,"font-family":"Noto Serif SC, Songti SC, serif","font-size":14},o),parent);t.textContent=s;return t}
function closedPath(pts){let d="";const n=pts.length;for(let i=0;i<n;i++){const p0=pts[(i-1+n)%n],p1=pts[i],p2=pts[(i+1)%n],p3=pts[(i+2)%n];if(i===0)d+=`M${p1[0].toFixed(1)},${p1[1].toFixed(1)}`;const c1=[p1[0]+(p2[0]-p0[0])/6,p1[1]+(p2[1]-p0[1])/6],c2=[p2[0]-(p3[0]-p1[0])/6,p2[1]-(p3[1]-p1[1])/6];d+=`C${c1[0].toFixed(1)},${c1[1].toFixed(1)} ${c2[0].toFixed(1)},${c2[1].toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`}return d+"Z"}
function openPath(pts){let d=`M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`;for(let i=0;i<pts.length-1;i++){const p0=pts[Math.max(0,i-1)],p1=pts[i],p2=pts[i+1],p3=pts[Math.min(pts.length-1,i+2)];const c1=[p1[0]+(p2[0]-p0[0])/6,p1[1]+(p2[1]-p0[1])/6],c2=[p2[0]-(p3[0]-p1[0])/6,p2[1]-(p3[1]-p1[1])/6];d+=`C${c1[0].toFixed(1)},${c1[1].toFixed(1)} ${c2[0].toFixed(1)},${c2[1].toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`}return d}
function paperGrain(svg,id,base){const defs=el("defs",{},svg);const f=el("filter",{id,x:0,y:0,width:"100%",height:"100%"},defs);el("feTurbulence",{type:"fractalNoise",baseFrequency:.85,numOctaves:2,seed:7,result:"n"},f);el("feColorMatrix",{type:"matrix",values:"0 0 0 0 0.45 0 0 0 0 0.38 0 0 0 0 0.3 0 0 0 .07 0"},f);el("rect",{width:600,height:800,fill:base},svg);el("rect",{width:600,height:800,filter:`url(#${id})`},svg)}
/* ---------- data (computed with Swiss Ephemeris, Lahiri) ---------- */
const P={Sun:33.04,Moon:116.20,Mars:1.73,Mercury:51.47,Jupiter:193.83,Venus:62.52,Saturn:317.56,Rahu:210.06,Ketu:30.06};
const ASC=70.98, BIRTH=1994.38;
const ZH={Sun:"太阳",Moon:"月亮",Mars:"火星",Mercury:"水星",Jupiter:"木星",Venus:"金星",Saturn:"土星",Rahu:"罗睺",Ketu:"计都"};
const DASHA=[["Mercury",BIRTH,1999.17],["Ketu",1999.17,2006.17],["Venus",2006.17,2026.17],["Sun",2026.17,2032.17],["Moon",2032.17,2042.17],["Mars",2042.17,2049.17],["Rahu",2049.17,2067.17],["Jupiter",2067.17,2083.17],["Saturn",2083.17,2102.17],["Mercury",2102.17,BIRTH+120]];
const LINE={
Sun:"这一圈,轮到你自己站到光里。",Moon:"这一圈,先照顾好心里那个家。",Mars:"这一圈,力气要花在一件事上。",
Mercury:"这一圈,学、写、说,都会被听见。",Jupiter:"这一圈,有人愿意为你把门打开。",Venus:"这一圈,美和关系是你的功课。",
Saturn:"这一圈,慢,但每一步都算数。",Rahu:"这一圈,你会想走到更远的地方。",Ketu:"这一圈,放下的会比拿到的多。"};
const fmt=y=>{const yr=Math.floor(y),m=Math.round((y-yr)*12)+1;return `${yr}.${String(Math.min(m,12)).padStart(2,"0")}`};
/* ---------- A · 年轮 ---------- */
function drawA(now){
const svg=document.getElementById("pA");svg.innerHTML="";paperGrain(svg,"gA",C.paper);
const R=rng(19940518);const cx=300,cy=392,K=218/120;
const ph=[R()*6.28,R()*6.28,R()*6.28,R()*6.28];
const shape=(t,r)=>1+.035*Math.sin(t+ph[0])+.022*Math.sin(2*t+ph[1])+.012*Math.sin(3*t+ph[2])+(.8/Math.max(r,20))*Math.sin(7*t+ph[3]);
const ringPts=(r,jit,rr)=>{const pts=[];for(let i=0;i<96;i++){const t=i/96*Math.PI*2,k=r*shape(t,r)+jit*(rr()-.5);pts.push([cx+Math.cos(t)*k+r*.05,cy+Math.sin(t)*k*.97])}return pts};
const tex={Saturn:2.1,Mercury:3,Sun:3.4,Moon:4,Venus:5,Mars:4.6,Jupiter:7,Rahu:3.4,Ketu:4.2};
const g=el("g",{fill:"none"},svg);
const age=now-BIRTH;
DASHA.forEach(([lord,s,e])=>{
const r0=6+(s-BIRTH)*K,r1=6+(e-BIRTH)*K,lived=e<=now,cur=s<=now&&now<e;
const col=lived||cur?C.ink:C.faint;
const rr=rng(Math.round(s*100));
for(let r=r0+tex[lord];r<r1-1;r+=tex[lord]){
const inner=el("path",{d:closedPath(ringPts(r,lord==="Moon"?2.4:.9,rr)),stroke:col,"stroke-width":lord==="Venus"?.5:.4,opacity:lived||cur?.55:.75},g);
if(lord==="Rahu")inner.setAttribute("stroke-dasharray","6 3");
if(lord==="Ketu")inner.setAttribute("stroke-dasharray","1 3.2");
}
if(lord==="Mars"){for(let i=0;i<9;i++){const t=rr()*6.28,a=r0+2,b=r1-2;el("path",{d:`M${cx+Math.cos(t)*a},${cy+Math.sin(t)*a}L${cx+Math.cos(t+.01)*b},${cy+Math.sin(t+.01)*b}`,stroke:col,"stroke-width":.5,opacity:.6},g)}}
el("path",{d:closedPath(ringPts(r1,.6,rr)),stroke:col,"stroke-width":lived||cur?1.15:.9},g);
el("path",{d:closedPath(ringPts(r1+.6,1.4,rr)),stroke:col,"stroke-width":.35,opacity:.5},g);
});
el("circle",{cx:cx+.3,cy,r:3.2,fill:C.ink},g);
// 上升方向的细线 + 此刻
const th=Math.PI+ (ASC%30)/30*Math.PI*.5; // 双子 10°58′ → 左上方
const rNow=6+age*K;const shp=shape(th,rNow);
const px=cx+Math.cos(th)*rNow*shp+rNow*.05,py=cy+Math.sin(th)*rNow*shp*.97;
const rEnd=6+120*K+6;
el("path",{d:`M${cx},${cy}L${cx+Math.cos(th)*rEnd*1.04},${cy+Math.sin(th)*rEnd*1.02}`,stroke:C.ink2,"stroke-width":.5,"stroke-dasharray":"2 3"},svg);
el("circle",{cx:px,cy:py,r:9,fill:C.ochre,opacity:.18},svg);
el("circle",{cx:px,cy:py,r:4.2,fill:C.ochre},svg);
const [lord,s,e]=DASHA.find(([,s,e])=>s<=now&&now<e);
txt(svg,48,64,"JYOTISHA · 年 轮",{"font-size":12,fill:C.ink2,"letter-spacing":2});
txt(svg,552,64,`${Math.floor(now)}`,{"font-size":12,fill:C.ink2,"text-anchor":"end","letter-spacing":1});
el("line",{x1:48,y1:80,x2:552,y2:80,stroke:C.faint,"stroke-width":.8},svg);
txt(svg,48,146,LINE[lord],{"font-size":30,"font-weight":600,fill:C.ink});
el("line",{x1:48,y1:650,x2:552,y2:650,stroke:C.faint,"stroke-width":.8},svg);
const yrIn=Math.floor(now-s)+1,tot=Math.round(e-s);
txt(svg,48,686,`${ZH[lord]}大运`,{"font-size":16,"font-weight":600});
txt(svg,48,712,`${fmt(s)} — ${fmt(e)} · 第 ${yrIn} 年,共 ${tot} 年`,{"font-size":13,fill:C.ink2});
txt(svg,552,686,"墨色是走过的,浅色是还没走的",{"font-size":12,fill:C.ink2,"text-anchor":"end"});
txt(svg,552,712,"赭点:此刻",{"font-size":12,fill:C.ochre,"text-anchor":"end"});
txt(svg,300,764,"仅供自我探索,不替你做决定。",{"font-size":11,fill:C.ink2,"text-anchor":"middle"});
}
/* ---------- B · 一笔画 ---------- */
function drawB(){
const svg=document.getElementById("pB");paperGrain(svg,"gB",C.paper);
const cx=300,cy=400,Rr=190;const R=rng(51871);
const pos=lon=>{const a=Math.PI+(lon-ASC)*Math.PI/180;return[cx+Math.cos(a)*Rr,cy-Math.sin(a)*Rr]}; // 上升在左,逆时针
// 铅笔导引圆
for(let k=0;k<2;k++){const pts=[];for(let i=0;i<120;i++){const t=i/120*6.283;const r=Rr+(R()-.5)*1.4+k*1.2;pts.push([cx+Math.cos(t)*r,cy+Math.sin(t)*r])}el("path",{d:closedPath(pts),fill:"none",stroke:C.faint,"stroke-width":.7},svg)}
const order=["Sun","Moon","Mars","Mercury","Jupiter","Venus","Saturn","Rahu","Ketu"];
const pts=order.map(k=>pos(P[k]));
// 在相邻点之间加一个略向圆心弯的控制点,让线条有呼吸
const path=[];pts.forEach((p,i)=>{path.push(p);if(i<pts.length-1){const q=pts[i+1];const m=[(p[0]+q[0])/2,(p[1]+q[1])/2];const pull=.18+R()*.12;path.push([m[0]+(cx-m[0])*pull+(R()-.5)*14,m[1]+(cy-m[1])*pull+(R()-.5)*14])}});
const g=el("g",{fill:"none","stroke-linecap":"round","stroke-linejoin":"round"},svg);
[[0,0,1.6,.9],[1.2,-.8,.7,.45],[-.9,1.1,.5,.35]].forEach(([dx,dy,w,o])=>{el("path",{d:openPath(path.map(p=>[p[0]+dx,p[1]+dy])),stroke:C.ink,"stroke-width":w,opacity:o},g)});
const SH={Sun:"日",Moon:"月",Mars:"火",Mercury:"水",Jupiter:"木",Venus:"金",Saturn:"土",Rahu:"罗",Ketu:"计"};
const placed=[];
order.map((k,i)=>[k,i]).sort((a,b)=>P[a[0]]-P[b[0]]).forEach(([k,i])=>{const [x,y]=pts[i];el("circle",{cx:x,cy:y,r:k==="Moon"?4.5:2.6,fill:k==="Moon"?C.ochre:C.ink},svg);
const a=Math.atan2(y-cy,x-cx);let d=22;while(placed.some(([px,py])=>Math.hypot(px-(cx+Math.cos(a)*(Rr+d)),py-(cy+Math.sin(a)*(Rr+d)))<15))d+=15;
const lx=cx+Math.cos(a)*(Rr+d),ly=cy+Math.sin(a)*(Rr+d);placed.push([lx,ly]);txt(svg,lx,ly+4,SH[k],{"font-size":12,fill:C.ink2,"text-anchor":"middle"})});
// 上升记号
el("line",{x1:cx-Rr-10,y1:cy,x2:cx-Rr+10,y2:cy,stroke:C.ochre,"stroke-width":1.4},svg);
txt(svg,cx-Rr-16,cy+4,"升",{"font-size":12,fill:C.ochre,"text-anchor":"end"});
txt(svg,48,64,"JYOTISHA · 一 笔",{"font-size":12,fill:C.ink2,"letter-spacing":2});
el("line",{x1:48,y1:80,x2:552,y2:80,stroke:C.faint,"stroke-width":.8},svg);
txt(svg,48,146,"脑子先出门,心留在家里。",{"font-size":30,"font-weight":600});
el("line",{x1:48,y1:650,x2:552,y2:650,stroke:C.faint,"stroke-width":.8},svg);
txt(svg,48,686,"双子上升 · 巨蟹月亮",{"font-size":16,"font-weight":600});
txt(svg,48,712,"九星按出生时的位置落在圆上,依日月火水木金土罗计连成一笔",{"font-size":12.5,fill:C.ink2});
txt(svg,552,686,"赭点:月亮,也是 AK",{"font-size":12,fill:C.ochre,"text-anchor":"end"});
txt(svg,300,764,"仅供自我探索,不替你做决定。",{"font-size":11,fill:C.ink2,"text-anchor":"middle"});
}
/* ---------- C · 那一刻的天空 ---------- */
function drawC(){
const svg=document.getElementById("pC");paperGrain(svg,"gC",C.night);
const cx=300,cy=400,Rr=200,LAT=30.27*Math.PI/180,LST=350.59;
const proj=(alt,az)=>{const r=(90-alt)/90*Rr,a=az*Math.PI/180;return[cx-r*Math.sin(a),cy-r*Math.cos(a)]}; // 北上东左
const altaz=(raH,dec)=>{const H=(LST-raH*15)*Math.PI/180,d=dec*Math.PI/180;const sa=Math.sin(d)*Math.sin(LAT)+Math.cos(d)*Math.cos(LAT)*Math.cos(H);const alt=Math.asin(sa);let az=Math.atan2(-Math.cos(d)*Math.sin(H),Math.sin(d)*Math.cos(LAT)-Math.cos(d)*Math.sin(LAT)*Math.cos(H));return[alt*180/Math.PI,(az*180/Math.PI+360)%360]};
const S={Sirius:[6.752,-16.72,-1.4],Arcturus:[14.261,19.18,0],Vega:[18.616,38.78,0],Capella:[5.278,46,.1],Rigel:[5.242,-8.2,.1],Procyon:[7.655,5.22,.4],Betelgeuse:[5.919,7.41,.5],Altair:[19.846,8.87,.8],Aldebaran:[4.599,16.51,.9],Antares:[16.49,-26.43,1],Spica:[13.42,-11.16,1],Pollux:[7.755,28.03,1.1],Fomalhaut:[22.961,-29.62,1.2],Deneb:[20.69,45.28,1.3],Regulus:[10.14,11.97,1.4],Castor:[7.577,31.89,1.6],Polaris:[2.53,89.26,2],Hamal:[2.12,23.46,2],Alpheratz:[0.14,29.09,2.1],Mirfak:[3.405,49.86,1.8],Algol:[3.136,40.96,2.1],Denebola:[11.818,14.57,2.1],Alphard:[9.46,-8.66,2],Rasalhague:[17.582,12.56,2.1],Enif:[21.736,9.88,2.4],Markab:[23.079,15.21,2.5],Scheat:[23.063,28.08,2.4],Algenib:[0.22,15.18,2.8],Sadr:[20.37,40.26,2.2],Albireo:[19.512,27.96,3],
Dubhe:[11.062,61.75,1.8],Merak:[11.031,56.38,2.4],Phecda:[11.897,53.69,2.4],Megrez:[12.257,57.03,3.3],Alioth:[12.9,55.96,1.8],Mizar:[13.399,54.93,2.2],Alkaid:[13.792,49.31,1.9],
Caph:[0.153,59.15,2.3],Schedar:[0.675,56.54,2.2],Navi:[0.945,60.72,2.4],Ruchbah:[1.43,60.24,2.7],Segin:[1.907,63.67,3.4]};
const lines=[["Dubhe","Merak","Phecda","Megrez","Alioth","Mizar","Alkaid"],["Megrez","Dubhe"],["Caph","Schedar","Navi","Ruchbah","Segin"]];
const clip=el("clipPath",{id:"sky"},el("defs",{},svg));el("circle",{cx,cy,r:Rr},clip);
const g=el("g",{"clip-path":"url(#sky)"},svg);
// 高度圈
[30,60].forEach(a=>el("circle",{cx,cy,r:(90-a)/90*Rr,fill:"none",stroke:C.cream,"stroke-width":.4,opacity:.18,"stroke-dasharray":"1 4"},g));
const XY={};
for(const k in S){const[ra,dec,m]=S[k];const[al,az]=altaz(ra,dec);if(al<0)continue;XY[k]=proj(al,az);el("circle",{cx:XY[k][0],cy:XY[k][1],r:Math.max(.7,2.4-m*.55),fill:C.cream,opacity:.85},g)}
lines.forEach(L=>{const p=L.filter(k=>XY[k]).map(k=>XY[k]);if(p.length>1)el("path",{d:"M"+p.map(q=>q.join(",")).join("L"),fill:"none",stroke:C.cream,"stroke-width":.5,opacity:.4},g)});
// 行星(真实高度、方位;swisseph 方位自南起算,已换成自北)
const PL={Sun:[31.71,84.69],Mars:[52.33,117.24],Mercury:[17.78,71.14],Venus:[7.95,65.88],Saturn:[50.31,191.14]};
for(const k in PL){const[x,y]=proj(...PL[k]);if(k==="Sun"){el("circle",{cx:x,cy:y,r:16,fill:C.ochre,opacity:.18},g);el("circle",{cx:x,cy:y,r:6,fill:"none",stroke:C.ochre,"stroke-width":1.4},g);el("circle",{cx:x,cy:y,r:1.6,fill:C.ochre},g)}else el("circle",{cx:x,cy:y,r:3.2,fill:C.cream},g);
txt(svg,x+(k==="Venus"?-9:9),y+(k==="Venus"?14:-7),ZH[k],{"font-size":11,fill:k==="Sun"?C.ochre:C.cream,opacity:.9,"text-anchor":k==="Venus"?"end":"start"})}
el("circle",{cx,cy,r:Rr,fill:"none",stroke:C.cream,"stroke-width":.9,opacity:.6},svg);
[["北",0,-1],["南",0,1],["东",-1,0],["西",1,0]].forEach(([s,dx,dy])=>txt(svg,cx+dx*(Rr+18),cy+dy*(Rr+18)+4,s,{"font-size":12,fill:C.cream,opacity:.6,"text-anchor":"middle"}));
txt(svg,48,64,"JYOTISHA · 那 一 刻",{"font-size":12,fill:C.cream,opacity:.6,"letter-spacing":2});
el("line",{x1:48,y1:80,x2:552,y2:80,stroke:C.cream,"stroke-width":.6,opacity:.25},svg);
txt(svg,48,146,"星星都在,只是还看不见。",{"font-size":30,"font-weight":600,fill:C.cream});
el("line",{x1:48,y1:650,x2:552,y2:650,stroke:C.cream,"stroke-width":.6,opacity:.25},svg);
txt(svg,48,686,"你出生时,太阳已经升起两个多小时",{"font-size":15,"font-weight":600,fill:C.cream});
txt(svg,48,712,"月亮与木星在脚下,土星正悬在南方",{"font-size":12.5,fill:C.cream,opacity:.65});
txt(svg,300,764,"仅供自我探索,不替你做决定。",{"font-size":11,fill:C.cream,opacity:.5,"text-anchor":"middle"});
}
drawA(2026.75);drawB();drawC();
const yr=document.getElementById("year"),out=document.getElementById("yearOut");
yr.addEventListener("input",()=>{const v=+yr.value;out.textContent=Math.floor(v);drawA(v)});
if(document.fonts)document.fonts.ready.then(()=>{drawA(+yr.value)});
</script>