From 5a1dcbd2a14805d688440f4d1da0f0c8f46b59b5 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 18 Sep 2026 00:39:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(chart):=20=E6=98=9F=E7=9B=98=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E8=A1=8C=E6=98=9F=E7=AC=A6=E5=8F=B7=E4=B8=8E=E9=A2=9C?= =?UTF-8?q?=E8=89=B2=EF=BC=8C=E5=BA=A6=E6=95=B0=E8=BF=9B=E8=A1=8C=E6=98=9F?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 北印星盘(星盘页 / 校正右栏 / 我的报告共用 VedicChartSvg)宫内不再画 「水 19°」「罗逆 29°」这类显示文本——一宫三颗星就叠三行、字号压到 8。 改成九个彩色符号横排,每行最多 3 个;逆行是符号下方一道同色横线, 第 1 宫加 As 小标,盘下方两行图例。度数、星宿、顺逆下沉到星盘页的 行星表,列从四列扩到八列,首行是上升,度数精确到分。 - 符号字体:vendored 的 9 字形 Noto Sans Symbols 子集(OFL,1.5 KB), 经 next/font/local 挂成 --font-planet-glyphs;♀ ♂ 带 U+FE0E 防 iOS 画成表情。构建镜像不能联网,所以必须自带,与 Inter 同一条理由。 - 颜色:--color-planet-* 九个 token,:root 与两个深色块各一份。只用于 认星不表吉凶,罗计共用中性灰。挂 is-* 修饰类而非行内 style——报告 文档的标记合同禁止渲染结果出现 style=,星盘不做例外。 - 数据来源:toNorthIndianChart() 加结构化 occupantGlyphs(星盘页与 markdown fence 走这条);校正与报告两条路线的模型只有引擎 / golden 显示文本,按首字解析,解析由测试锁住。 - 星宿沿用引擎返回名:仓库无中文宿名表,印度 27 宿与中国二十八宿不是 同一套,不自造对照。 tsc 0 错,lint 0 error,npm test 3471→3484 条、36 红与基线逐条相同, / 仍 Static,首屏 JS gzip +0.29%。 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- CHANGELOG.md | 16 + docs/tasks/PROGRESS-chart-glyphs-20260918.md | 99 ++++++ docs/tasks/README.md | 1 + frontend/DESIGN.md | 39 ++- .../app/fonts/NotoSansSymbols-planets.woff2 | Bin 0 -> 1536 bytes frontend/src/app/fonts/SOURCE.txt | 11 + frontend/src/app/globals.css | 137 +++++++- frontend/src/app/layout.tsx | 18 +- .../src/components/chart-page/chart-format.ts | 14 + .../components/chart-page/chart-vedic-tab.tsx | 99 +++++- .../personal-report/vedic-chart-svg.tsx | 294 ++++++++++++++---- frontend/src/lib/chart-view-contract.ts | 30 ++ frontend/src/lib/chart-view-mapper.ts | 27 ++ .../src/lib/north-indian-chart-geometry.ts | 18 ++ frontend/src/lib/planet-glyphs.ts | 186 +++++++++++ frontend/src/lib/report-chart-block.ts | 41 ++- frontend/tests/chart-page-view.test.tsx | 14 +- frontend/tests/chart-planet-glyphs.test.tsx | 243 +++++++++++++++ .../personal-report-markdown-view.test.tsx | 17 +- .../site-style-isolation-contract.test.ts | 8 +- 20 files changed, 1239 insertions(+), 73 deletions(-) create mode 100644 docs/tasks/PROGRESS-chart-glyphs-20260918.md create mode 100644 frontend/src/app/fonts/NotoSansSymbols-planets.woff2 create mode 100644 frontend/src/lib/planet-glyphs.ts create mode 100644 frontend/tests/chart-planet-glyphs.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 269dc3fb..293c012c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # 印度占星 Skill 更新日志 +## 2026-09-18 — 星盘改用行星符号,度数进行星表 + +星盘里的行星从文字换成彩色符号:☉ 日、☽ 月、☿ 水、♀ 金、♂ 火、♃ 木、♄ 土、☊ 罗、☋ 计。以前一宫里三颗星就是三行「水 19°」「罗逆 29°」叠在一起、字被压到最小,现在一宫内符号横排,每行最多三个。 + +- 逆行改成符号下面一道同色横线,不再写「逆」字。 +- 第 1 宫多了一个红色 `As` 小标——北印度盘第 1 宫就是上升宫。 +- 盘下方多了一行图例:九个符号各配一个汉字,末尾「横线=逆行」。只在盘里有星时出现。 +- 颜色只用来认星,不代表吉凶;罗睺与计都共用一个中性灰。深浅两个主题各一套颜色。 +- 三处星盘同时生效:星盘页、生时校正右栏、我的报告。 + +度数、星宿、顺逆改在**星盘页**下方的行星表里看,列从四列扩到八列:行星 / 黄道经度 / 速度 / 宫位 / 星宿 / 宿主 / pada / 状态。度数精确到分(29°59′ 不再显示成 30°),第一行是上升。表格在自己的盒子里横滚,页面不横滚。 + +读屏与宫位表读到的还是完整文本(「水 19°」),符号只改看的那一层。星宿名沿用引擎返回的名字:印度 27 宿与中国二十八宿不是同一套,没有可靠对照就不译。 + +Skill 版本不变(本轮只动前端渲染,不动解读口径)。 + ## 2026-09-17 — 对话口气:先给反差,再说去扮演哪个象 普通对话的回答不再以一句判词开头,改成固定形状:先给反差(「表面 A,底下 B」,并把它命名成一个格局,例如「外松内紧」),再说清这段时间**谁在推、谁在修**(大运主星在推,行运只负责把结果修得体面),然后是「别去应 X 的象,去扮演 Y 的象」,最后最多三条短行动(破折号短句,每条不超过 20 字,做完就能看到改变)。开场仍无标题、总长不超过 400 字,后面的完整骨架与文末技法审计表照旧。 diff --git a/docs/tasks/PROGRESS-chart-glyphs-20260918.md b/docs/tasks/PROGRESS-chart-glyphs-20260918.md new file mode 100644 index 00000000..2eafef3d --- /dev/null +++ b/docs/tasks/PROGRESS-chart-glyphs-20260918.md @@ -0,0 +1,99 @@ +# PROGRESS · 星盘改用行星符号,度数进行星表(2026-09-18) + +- 分支:`codex/chart-glyphs-20260918`,worktree `.worktrees/chart-glyphs-20260918` +- 基线:`origin/staging` = `84b293fb`(feat(consult): 对话口气改成反差与扮演象的形状) +- 类型:产品拍板的界面改造,非 Bug,未写 `docs/BUG_HISTORY.md` +- Skill 版本:不变 + +## 1. 做了什么 + +盘里(星盘页 / 校正右栏 / 我的报告,同一个 `VedicChartSvg`)不再画「水 19°」「罗逆 29°」这类显示文本,改画彩色行星符号;度数、星宿、顺逆下沉到**星盘页**的行星表。 + +| 项 | 落点 | +| --- | --- | +| 符号字体 | `frontend/src/app/fonts/NotoSansSymbols-planets.woff2`(1,536 B,OFL,9 字形子集),来源与取法追加在 `SOURCE.txt` | +| 字体挂载 | `layout.tsx` 第二个 `localFont`,`variable: "--font-planet-glyphs"`、`display: "swap"`、`adjustFontFallback: false` | +| 符号 / 名称 / 缩写 / 颜色类 / 解析 | 新模块 `frontend/src/lib/planet-glyphs.ts` | +| 盘面渲染 | `frontend/src/components/personal-report/vedic-chart-svg.tsx` 重写占位层 | +| 宫内锚点 | `north-indian-chart-geometry.ts` 新增 `occupantAnchor()` | +| 结构化字段 | `report-chart-block.ts` 的 `toNorthIndianChart()` 产出 `occupantGlyphs`;`chart-view-contract.ts` 的 `northIndianChartSchema` 加同名可选字段 | +| 上升行 | `chart-view-contract.ts` 新增 `chartViewAscendantRowSchema`;`chart-view-mapper.ts` 新增 `ascendantRow()`,挂在 `vedic.ascendant` | +| 行星表 | `chart-vedic-tab.tsx` 四列 → 八列;`chart-format.ts` 新增 `formatDegreeMinutes()` | +| 颜色与样式 | `globals.css`:9 个 `--color-planet-*`(`:root` + 两个深色块 + `@theme inline` 各一份)、符号 / 图例 / 逆行横线 / As / 表格列样式 | + +### 数据来源:结构化优先,两条路线仍按显示文本解析 + +任务书要求「优先加结构化字段」。实际分成两条: + +- **加了结构化字段**:`toNorthIndianChart()` 在格式化字符串之前就知道星体与逆行,直接挂 `occupantGlyphs: { key, retrograde, degree }[]`。星盘页(`chart-view-mapper` → `buildVargas`)与 markdown 里的 `jyotish-chart` fence 都走这条,渲染层不解析字符串。`chartViewOkSchema.parse()` 会剥未声明字段,所以同步在 `northIndianChartSchema` 里加了可选字段,否则结构化信息会在序列化边界丢掉。 +- **仍按显示文本解析**: + - **校正右栏**:`houseTableToNorthIndianChart()` 的输入是 `parseRectificationHouseTable()` 解出来的引擎 `house_table`,只有 `{house, sign, occupants: string[]}`,且 `scripts/rectification/house_table.py` 给的是**全名**(`太阳` / `水星` / `罗睺`),没有逆行位、没有度数。 + - **我的报告**:`ChartV1.houses` 是 `personal-report-contract.ts` 里的 `z.strictObject`,形状来自 Python golden(`personal_report_contract.py`),按 AGENTS §7.4 不得手造。 + + 这两条走 `parseChartOccupant()`:首字 ∈ `日太月火水木金土罗计`(`太` 是为 `太阳`),或首段拉丁字母 ∈ `Sun…Ketu`,逆行看是否含「逆」,并与 `chart.planets[].retrograde` 取或。认不出来的标签按原样画字(回归到旧行为,不吞数据)。解析本身由 `tests/chart-planet-glyphs.test.tsx` 的三条用例锁住(`水 19°` / `罗逆 29°`、全名、英文名、未知名)。 + +### 两个非任务书写法的选择 + +1. **颜色挂修饰类而不是行内 `style`。** 第一版按预览稿写了 `style={{ fill: … }}`,撞到 `tests/personal-report-view.test.ts` 的 `assert.doesNotMatch(markup, /style\s*=/i)`——报告文档的标记合同禁止渲染结果里出现 `style=`,这条是挡 agent 产出的内容自带 CSS 的,不该为星盘破例。改成 `is-sun`…`is-node` / `is-ascendant` 修饰类,`text` 吃 `fill`、`line` 吃 `stroke`(合并成一条会给符号描边)。 +2. **星宿名沿用引擎返回值,不译中文。** 任务书写「星宿中文」,但仓库里(前端 `chart-view-labels.ts`、Python `scripts/`)都没有星宿中文名表,`chart-basics-tab.tsx` 现在也是直接显示引擎返回的名字。印度 27 宿与中国二十八宿不是同一套,现造一张对照表是在下一个领域判断,超出本单授权,已写进 `DESIGN.md` §15。 + +### 其它细节 + +- 图例两行(5 + 4 + 「横线=逆行」)。九对符号 + 汉字加那句话在 400 单位一行里会挤,按任务书让步走两行;图例落在方阵下方的 52 单位条带里,viewBox 高度由 400 变 452,不压宫位。 +- `As` 不占 `MAX_RENDERED_OCCUPANTS` 名额:它来自几何不来自数据,占名额会让第 1 宫比别的宫少显示一颗星。 +- `.chart-page-vedic-stage … text { font-size: 16px }` 这条通选被拆开(见下节)。 +- 打印:`@media print` 里 `* { print-color-adjust: exact !important }` 已在,符号是普通 ``,颜色 token 在 `:root` 定义、打印块不覆盖,符号照常可见且带色。 + +## 2. 测试数字(同机、同命令,各测一次) + +| 项 | 基线 `84b293fb` | 本轮 | 差 | +| --- | --- | --- | --- | +| `./node_modules/.bin/tsc --noEmit` | 0 错 | 0 错 | — | +| `npm run lint` | 0 error / 118 warning | 0 error / 118 warning | 未新增 warning | +| `npm test` tests | 3471 | 3484 | +13(新文件 `tests/chart-planet-glyphs.test.tsx`) | +| `npm test` pass | 3420 | 3433 | +13 | +| `npm test` fail | 36 | 36 | 排序后 `diff` 为空 | +| `npm test` skipped | 15 | 15 | — | +| `next build` 的 `/` | `○ Static` | `○ Static` | 通过 | +| 首屏 gzip(`.next/static` 全部 `*.js` 逐个 gzip 合计,68 个文件) | 1,450,774 B | 1,455,017 B | +4,243 B,**+0.29%**,在 ±2% 内 | +| CSS gzip(`.next/static` 全部 `*.css`,4 个文件) | 40,215 B | 41,110 B | +895 B,+2.23% | +| 字体资产 | — | `NotoSansSymbols_planets.*.woff2` 1,536 B | 独立 media 资产,`display: swap`,不计入首屏 JS | + +CSS +2.23% 的构成:9 个颜色 token × 3 个块 + `@theme inline` 一份(36 行)、17 条 `is-*` 上色规则(盘面 `text`/`line` 各一套)、9 条表格上色规则、符号 / 图例 / 逆行 / As / 表格列的字号与对齐。这一节的口径按 `PROGRESS-starter-greeting-20260917.md` 的先例是 `*.js`,CSS 单列出来是因为本轮变化确实落在 CSS 上,JS 与 CSS 合计 1,490,989 → 1,496,127 = +0.34%。 + +失败清单:基线 36 条与本轮 36 条排序后 `diff` 为空(`database` / `live staging sync` / `ephemeris` / `admin` 等套件)。全部是无 Docker、无数据库、无外部引擎的既有环境缺口,与本轮无关。 + +`next build` 取基线数字的方法(未用 `git stash`,AGENTS §3.5):`git diff > /tmp/…/chart-glyphs.patch` → `git checkout -- frontend/` → 把三个**未跟踪**新文件临时移出 worktree(否则 `tsc` 会在基线上报 `occupantGlyphs` 不存在,构建 exit 1、`.next/static` 是脏的)→ `rm -rf .next && npm run build` → 取数 → `git apply` + 移回。两侧都是干净 `.next` 的全量构建。 + +## 3. 被改的既有断言(原值 / 新值 / 原因) + +| 文件 · 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `tests/chart-page-view.test.tsx` · `the parameters are one key/value table…` | `assert.match(globalStyles, /\.chart-page-vedic-stage \.personal-report-chart-svg text \{ font-size: 16px; \}/)` | 改断 `.personal-report-chart-muted` 那一条仍是 16px,并**新增** `assert.doesNotMatch(globalStyles, /\.chart-page-vedic-stage \.personal-report-chart-svg text \{/)` | 盘内文字不再只有一种字号:符号按窄盘宽度定在 22 viewBox 单位。通选 `text` 规则会把符号一起压回 16,正是要避免的。DESIGN §15 的原意是「小字在 ~318px 盘宽下不低于 12px」,不是「所有字一样大」。断言主语(宫位序号仍 16)未改,新增的反向断言比原断言更强 | +| `tests/personal-report-markdown-view.test.tsx` · `markdown view draws the North Indian component…` | `assert.match(markup, new RegExp(planetDisplayLabel("Saturn", 3.4, true)))`(盘里必须出现「土逆 3°」) | 改断盘里出现 `♄` 且带 `class="personal-report-chart-retrograde is-saturn"`;**新增** `doesNotMatch` 那串文本、`doesNotMatch(/°/)`、「逆」在整份标记里只剩图例那一个 | 产品拍板把盘内文字换成符号,原断言守的正是本轮要去掉的东西,无法原样保留。断言主语(这颗逆行土星来自 fence 数据、画在盘里)未改;新增的三条反向断言把「不再写字」这一半也锁住。`planetDisplayLabel()` 本身没删——`occupants` 字符串仍喂 `aria-label` 与宫位表 | +| `tests/site-style-isolation-contract.test.ts` · `Inter is loaded through next/font…` | `assert.match(layout, /className=\{inter\.variable\}/)` | `assert.match(layout, /className=\{`\$\{inter\.variable\}[^`]*`\}/)` | 本轮新增第二个 `next/font` 变量(`--font-planet-glyphs`),两个都要挂在同一个 `` 上。断言主语(Inter 以 CSS 变量形式挂在根元素、且是第一个)未改,只是不再要求 className 只有它一个 | + +被**扩写**但未弱化的既有断言:`tests/personal-report-view.test.ts` 的 `each house owns its occupant coordinates…` 与 `no dangerous HTML…` 两条**一字未改**且仍绿——前者的 `+7` 是靠「`As` 不占溢出名额」保住的,后者的 `doesNotMatch(/style\s*=/i)` 是本轮改用修饰类的直接原因。 + +## 4. 新增测试(`tests/chart-planet-glyphs.test.tsx`,13 条) + +1. 含「水 19°」「罗逆 29°」的宫渲染出 ☿ 与 ☊,☊ 带 `personal-report-chart-retrograde`,☿ 不带;盘里不再有「°」,「逆」只剩图例那一个。 +2. 校正面的全名(太阳 / 水星 / 罗睺 / 计都)映射到同一组符号。 +3. `PLANET_GLYPH.venus` / `.mars` 逐字等于 `♀`+U+FE0E / `♂`+U+FE0E,且渲染结果里带。 +4. 第 1 宫有且只有一个 `As`,挂 `is-ascendant`。 +5. 显式写出的上升占位(`上升点` / `Lagna`)并入同一个 `As`,不画两次。 +6. 图例只在有星时出现(空盘 viewBox 仍是 `0 0 400 400`),出现时九个符号 + 九个汉字齐全。 +7. 一宫 6 颗星恰好两行(`y` 去重后 2 个);第 7 颗起出现 `+2`。 +8. `toNorthIndianChart()` 挂上 `occupantGlyphs`,同时 `occupants` 字符串原样保留。 +9. 认不出的占位名按原样画字。 +10. `parseChartOccupant()` 覆盖四种在用写法 + 未知名。 +11. 9 个颜色 token 在 `:root`、`prefers-color-scheme` 深色块、`[data-theme="dark"]` 深色块**各恰好一次**,且深浅值不相同。 +12. 字体是 vendored `wOF2`、`--font-planet-glyphs` 存在、没有 `next/font/google`、字体栈逐字正确、`SOURCE.txt` 记了许可证。 +13. 星盘页表头恰为「行星 黄道经度 速度 宫位 星宿 宿主 pada 状态」,上升是第一行,度数带分,符号列上色,旧表头「星座与度数」已消失。 + +## 5. 没做 / 做不了 + +- **星宿中文名**:见 §1.2。仓库无对照表,不自造。 +- **参照里其它页签**(宫位 / 大运 / 星力 / 瑜伽):任务书明确本单不做,未做。 +- **真机走查**:无登录态、无 Chrome,以下留给 `docs/testing/`——iOS Safari 上 ♀ ♂ 是否确为文本呈现(U+FE0E 生效)、Android / Linux 上九个符号是否都落在 vendored 子集、深浅两主题下九种颜色在盘面与表格的实际可读性、375px 下行星表在自己的盒子里横滚而页面不横滚、报告打印时符号与颜色是否随纸出。 +- **`height="auto"`**:`VedicChartSvg` 上这个非法属性是 `TASK-console-noise-20260916`(BUG-906)的范围,本轮未顺手动。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index f482c98a..25ef5800 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -172,6 +172,7 @@ | `TASK-billing-pricing-20260830.md` | `PROGRESS-billing-pricing-20260830.md` | 计费闭环与功能级定价 | 已合入 | `aaaa5fb7`~`a499c344` | | `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**:侧栏两个 `
` 拍平成一条「最近」、星盘的两个入口(侧栏分组 + 账户菜单)收敛到一处、删掉逐条助手头像。**决策记录 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=`;**刻意不带**重命名/删除/收藏/归档——那套连着 `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 横滚、打印) | | `TASK-console-noise-20260916.md` | `PROGRESS-console-noise-20260916.md` | 控制台噪音清理:两个星盘 SVG 写了非法的 `height="auto"` 属性,每张盘报一次(BUG-906);`/api/consult/status` 无参查询在没有后台任务时返回 404,浏览器每次切回标签页都打红字,改 204、带参路径仍 404(BUG-907)。第三条 `reportAllChanges … startTime` 是浏览器扩展注入脚本,非本仓代码,不处理 | 待领取 | — | ### 待开的任务书(2026-09-03 审查决定) diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 37d653a6..1b0a3bf0 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -712,6 +712,26 @@ Admin 的 antd `` 是独立设计系统,不在此表。 **本命宫位改北印星盘。** 用 `VedicChartSvg` 渲染代表分钟的 `house_table`。宫位表退到星盘下方的 `
`(读屏与复制仍可用)。换分钟时只高亮变化的宫(`changedHouses` / `lagnaChanged`),不做动画。星盘组件在校正面 `dynamic()` 分包里,不进首页首屏。 +**盘里只放彩色行星符号(2026-09-18)。** 此前宫内画的是「水 19°」「罗逆 29°」这类显示文本,一宫三颗星就叠成三行、字号被压到 8。现在宫内只画符号,度数 / 星宿 / 逆行下沉到星盘页的行星表(§15)。星盘页、校正右栏、我的报告三处共用同一个 `VedicChartSvg`,所以三处同时生效;行星表只在星盘页。 + +| 元素 | 画法 | +| --- | --- | +| 行星 | ☉ 日 · ☽ 月 · ☿ 水 · ♀ 金 · ♂ 火 · ♃ 木 · ♄ 土 · ☊ 罗 · ☋ 计,每颗一个颜色 | +| 排布 | 一宫内横排,每行最多 3 个,第 4 颗起第二行;`MAX_RENDERED_OCCUPANTS` 仍是 6,第 7 颗起收成 `+N` | +| 逆行 | 符号正下方一道同色短横线,不再写「逆」字 | +| 上升 | 第 1 宫加一个上升色粗体小标 `As`。北印度盘第 1 宫恒为上升宫,这个标记来自几何不来自数据,也不占 `+N` 名额 | +| 图例 | 盘下方两行:九个符号各跟一个汉字,末尾「横线=逆行」。只在盘里有星时出现,出现时 viewBox 高度从 400 变 452 | + +颜色 token 在 `globals.css` 的 `:root` 与两个深色块各定义一份,**只用于认星,不表示吉凶**——罗计共用一个中性灰正是这个意思: + +`--color-planet-sun` / `-moon` / `-mars` / `-mercury` / `-jupiter` / `-venus` / `-saturn` / `-node` / `-ascendant`。 + +颜色挂在 `is-sun`…`is-node` / `is-ascendant` 修饰类上,不用行内 `style`——报告文档的标记合同禁止渲染结果里出现 `style=`,星盘不做例外。 + +符号字体是 vendored 的 9 字形 Noto Sans Symbols 子集(OFL,1.5 KB,`src/app/fonts/NotoSansSymbols-planets.woff2`,来源写在 `SOURCE.txt`),经 `next/font/local` 挂成 `--font-planet-glyphs`;字体栈是 `var(--font-planet-glyphs), "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", serif`。构建镜像不能联网,所以必须自带,和 Inter 同一条理由。♀ ♂ 后面跟 U+FE0E,否则 iOS 会画成表情。 + +`aria-label` 与宫位 `
` 表仍读 `occupants` 显示字符串,读屏拿到的还是「水 19°」这种完整信息;符号只改看的那一层。 + **折叠块只有两段(D10,2026-09-16)。** 此前盘面从上到下并排五个 `
`,一屏扫下去全是折叠头,看不出哪个该点。现在收敛成两段,星盘本身不折叠: | 段 | 装什么 | @@ -769,7 +789,24 @@ Admin 的 antd `` 是独立设计系统,不在此表。 ### 行星表 -三张 `.chart-page-planet-table`(印度盘宿度、西洋相位、七政十一曜)**每一张**都包在 `.chart-page-planet-table-wrap` 里:`min-width: 0` + `overflow-x: auto`,表本身 `min-width: 26rem`。375px 下表格在自己的盒子里横滚,页面不横滚。`min-width: 0` 和 overflow 一样是必需的——grid item 默认按内容定宽,少了它表格会把页面撑宽而不是滚动。 +三张 `.chart-page-planet-table`(印度盘宿度、西洋相位、七政十一曜)**每一张**都包在 `.chart-page-planet-table-wrap` 里:`min-width: 0` + `overflow-x: auto`。375px 下表格在自己的盒子里横滚,页面不横滚。`min-width: 0` 和 overflow 一样是必需的——grid item 默认按内容定宽,少了它表格会把页面撑宽而不是滚动。印度盘那张从四列扩到八列后 `min-width` 由 `26rem` 提到 `34rem`。 + +**印度盘行星表的八列(2026-09-18)。** 盘里不再写的东西全部落在这里: + +| 列 | 内容 | +| --- | --- | +| 行星 | 上色符号 + 中文名 + 灰色缩写(Su/Mo/Ma/Me/Ju/Ve/Sa/Ra/Ke,上升 As) | +| 黄道经度 | 星座 + 度°分′。不四舍五入到整度——29°59′ 与 0°01′ 差一个星座 | +| 速度 | 顺 / 逆 | +| 宫位 | N 宫 | +| 星宿 | 引擎返回的星宿名。**没有中文宿名表就不造一个**:印度 27 宿与中国二十八宿不是同一套,硬对上去是编事实 | +| 宿主 | 星宿主的上色符号,居中 | +| pada | 1–4,居中 | +| 状态 | 尊贵 / 庙旺等,原样保留,只是挪到最后一列 | + +第一行是上升。上升是盘点不是星体,引擎不返回它的星宿 / pada / 宿主,这三格写「—」而不是填一个看起来合理的值。数字列 `tabular-nums`,符号列与宿主列居中。 + +本轮不新增第二层页签:星盘页已有五个 Tab,参照里「宫位 / 大运 / 星力 / 瑜伽」等页签的内容后续另立单。 ### 三套坐标系 diff --git a/frontend/src/app/fonts/NotoSansSymbols-planets.woff2 b/frontend/src/app/fonts/NotoSansSymbols-planets.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..2b4f5ecc2795e5f73ecd9206d03e9ff5e1e76969 GIT binary patch literal 1536 zcmV+b2LJhYPew8T0RR9100sa65dZ)H01FTR00pD~0RR9100000000000000000000 z0000Q78@QMItE}rQ&d4zE&yH;2nvcYi3bZ700A}vBm*b}AO(az2Ot+D7#15M3CP5# zoK}w{2c!V6oh2=4Z7BqXpw_WMY{-@pR@guXKw$eAUrXfw|6bLMZ4)SY9^fb?H*>Ad zh5yuQ%Yvo7fR;_M2~U`|?+ z$!nwu&kQqk_uo2wd|(;;%xt6zycU>U0>3;NS_OaQD5l*RI*VKe9Y9?`xrnx_9fqi= zA~Lc(P2u;aRwpQZeF|O!FW7bO9eoGIbnj7C5Ghc4>I16bp?e6@98E#pwhmNOn-3A} zp+I97MJ}^-FKt$XBdP)_W$-ZQd>X+tP{5tMzzK~)i(rR-Juk3AqtGnK$z<~HZ}xxl z&Xa)5lY+FmJRLePNQDteCXs!X$f^oRZyrEC0;nYh9RmSR19Tnr2409_n5LYZrkoso z-X?vHDW`I64yVgxrZROI&I=L=a{7}}f}H-aqpKKd0o zJ&|B9h{bR;UV81l7Zt@~@`W!xifNC$Kzr_?)gFF6bFP_Y$2cE&LNIW3-&6DumvEfB z@GFcdyXr1UfGv+1FIvo;;H>k4cLZ{KIKh%<*(5C9(F?yiU`w!GE}LW!E?m!E5Cf}A zLpw)vOs4AE_f5`G=g8;-&=2yK-gifQgs_Ao?_8Mg`XE8L4JbKmX@6hTaCUHD;kk*y z=``H&^l3L0aF43q6{p>{?xx#tTHL_iU{&6^@zy)Z>0eyFlH!yoU`{~nv#TH6_<>ec zJ5!59s!MEEvyA$3M)t>-S($=Qi0f zbxz&XGin3Z1x2Gx)@Yp;f z!y8Q7o!d<&?Al zO7qWBK?)`s)ni^%Z-~zQm#$A)w=J^Cbw-zRuG_7gdwQ39)AyF%Gb&Y2^{Ksco||rU zYU^cx<_mNim5IQ4$qJ#9!6Rk@AR37_gMPYR$F@cCB^NQYDFsB1$ESXi}sq zt97!t*g9^hDR=c->s*oY$a3)(NfAX+>^4PYwn^egN;q|*yRLi6G4*`i^J7I@Hm02G z6zeCQ6Ju-s`E}PX*&El`CM6J>V*nKjvBP|m>HyY4tSb3D6YZyHVieKZ`~8~dZ(_vhEHC_VK5 z(GOFMx>rq@o-%AwV>^&CaOnTT@f4$}lV|NL<$_AvQSH;IO8mX}1$+w51BMqy3Dfpi z@(>E(03$+bdfn*=^d=_V<=#Xp5W!}Wg(zeZAR2bOgcwxe0;EGRd?*EFkoj4tLxzbk zcz?i&Su8-qJVIq_2tEX1MY*$E5Yq@?0X~GNsw@`a+b*LFUId__9DQ)08@=!%gdc7Q zlUPL%LpUBQn1UC}2%;2&n1zNgmNAI{MltJx5VIG1<6sraP=N|mM+V-Vi+6?bBiKGm z8x-`R4+?Bh#+Xe~edP{#;luIIMEb-4{_Yj1LKS!nV;-~42od}BBLFWJF^lQzJgo>n zWq5udcy8kp_h?u+s|wS@$}kNtR#1*rVVb#T2JZsi%_vYB(;hDNw@luIDrA);2V~E& mj4ytD`T;ui=)Nr_cO?LIjM`1@ra3x=euDax`b_s8Z2 + {/* Synchronous on purpose: a pinned theme must be on before the first paint, or the page flashes the other theme on every load. */} diff --git a/frontend/src/components/chart-page/chart-format.ts b/frontend/src/components/chart-page/chart-format.ts index 8461c8e0..cfc1f720 100644 --- a/frontend/src/components/chart-page/chart-format.ts +++ b/frontend/src/components/chart-page/chart-format.ts @@ -7,3 +7,17 @@ export function formatCoord(latitude: number, longitude: number): string { export function formatDegree(degree: number): string { return `${Math.min(29.9, Math.max(0, degree)).toFixed(1)}°`; } + +/** + * Degrees and arc-minutes inside the sign, e.g. `15°02′`. + * + * The planet table is the only place the chart page still states a position, so + * it does not round to whole degrees the way the chart labels used to: 29°59′ + * and 0°01′ are a sign apart and must not both read as "30°". + */ +export function formatDegreeMinutes(degree: number): string { + const clamped = Math.min(29.9999, Math.max(0, degree)); + const whole = Math.floor(clamped); + const minutes = Math.min(59, Math.floor((clamped - whole) * 60)); + return `${whole}°${String(minutes).padStart(2, "0")}′`; +} diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index 049d3f83..edcc34d1 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -1,11 +1,88 @@ "use client"; import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg"; -import type { ChartViewOk } from "@/lib/chart-view-contract"; +import type { + ChartViewAscendantRow, + ChartViewOk, + ChartViewPlanetRow, +} from "@/lib/chart-view-contract"; import { CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "@/lib/chart-view-labels"; -import { formatDegree } from "./chart-format"; +import { + ASCENDANT_COLOR_CLASS, + ASCENDANT_MARK, + PLANET_ABBR, + PLANET_COLOR_CLASS, + PLANET_GLYPH, + planetGlyphKey, +} from "@/lib/planet-glyphs"; +import { formatDegreeMinutes } from "./chart-format"; import { ChartParamTable } from "./chart-param-table"; +/** The same coloured symbol the chart draws, so a row and a house read as one body. */ +function BodyGlyph({ name }: { name: string }) { + const key = planetGlyphKey(name); + if (!key) return —; + return ( + + {PLANET_GLYPH[key]} + + ); +} + +function PlanetCell({ name, label, abbr }: { name: string | null; label: string; abbr: string }) { + return ( + <> + {name === null ? ( + + {ASCENDANT_MARK} + + ) : } + {" "} + {label} + {" "} + {abbr} + + ); +} + +function PlanetRow({ row }: { row: ChartViewPlanetRow }) { + const key = planetGlyphKey(row.name); + return ( + + + + + {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} + {row.retrograde ? "逆" : "顺"} + {row.house}宫 + {row.nakshatra} + + {row.pada} + {row.status} + + ); +} + +/** The ascendant is a chart point: the engine returns no nakshatra for it. */ +function AscendantRow({ row }: { row: ChartViewAscendantRow }) { + return ( + + + + + {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} + 顺 + {row.house}宫 + {row.nakshatra ?? "—"} + + {row.nakshatraLord ? : "—"} + + {row.pada ?? "—"} + — + + ); +} + export function ChartVedicTab({ view, vargaId, @@ -53,25 +130,27 @@ export function ChartVedicTab({ chart can and cannot be read for. */}

{view.vedic.boundary}

+ {/* Everything the chart stopped drawing lands here: the chart carries + symbols, this table carries the numbers behind them. */}
- - + + + + + + + {view.vedic.ascendant && } {view.vedic.planets.map((planet) => ( - - - - - - + ))}
{varga.id} · {varga.meaning}
星体星座与度数行星黄道经度速度 宫位星宿宿主pada 状态
{planet.label}{planet.signLabel} {formatDegree(planet.degreeInSign)}{planet.house}{planet.status}
diff --git a/frontend/src/components/personal-report/vedic-chart-svg.tsx b/frontend/src/components/personal-report/vedic-chart-svg.tsx index d477a754..ba62be8b 100644 --- a/frontend/src/components/personal-report/vedic-chart-svg.tsx +++ b/frontend/src/components/personal-report/vedic-chart-svg.tsx @@ -5,7 +5,15 @@ * * House geometry is the standard diamond chart: two diagonals plus the * inner rhombus from the four midpoints. Sign numbers are rasi ordinals - * (1 = Aries … 12 = Pisces). Occupant labels are already display text. + * (1 = Aries … 12 = Pisces). + * + * Planets are drawn as coloured symbols, not as text. The old label form + * ("水 19°", "罗逆 29°") stacked three lines on top of each other as soon as a + * house held three bodies, and the font size had to shrink to fit. Degrees, + * nakshatra and direction now live in the planet table under the chart page's + * chart; the chart itself answers only "which body sits where". + * + * Colour identifies a body. It is not a benefic/malefic signal. */ import { useId } from "react"; @@ -15,15 +23,47 @@ import { CHART_SIZE, HOUSE_NUMBERS, HOUSE_POLYGONS, - polygonCentroid, + occupantAnchor, polygonPoints, signNumberAnchor, } from "@/lib/north-indian-chart-geometry"; +import { + ASCENDANT_COLOR_CLASS, + ASCENDANT_MARK, + PLANET_COLOR_CLASS, + PLANET_GLYPH, + PLANET_GLYPH_ZH, + PLANET_LEGEND_ORDER, + parseChartOccupant, + planetGlyphKey, + type PlanetGlyphKey, +} from "@/lib/planet-glyphs"; import { signOrdinal, type NorthIndianChartModel } from "@/lib/report-chart-block"; export const CHART_VIEWBOX_WIDTH = CHART_SIZE; export const CHART_VIEWBOX_HEIGHT = CHART_SIZE; +/** Extra viewBox rows under the square, used only when the legend is drawn. */ +const LEGEND_BAND_HEIGHT = 52; +const LEGEND_ROW_ONE_Y = CHART_SIZE + 20; +const LEGEND_ROW_TWO_Y = CHART_SIZE + 42; +const LEGEND_COLUMN_WIDTH = 48; +const LEGEND_LABEL_OFFSET = 17; +// Two rows: nine symbol/name pairs plus the retrograde note do not fit on one +// 400-unit line at legend size without crowding. +const LEGEND_ROW_ONE = PLANET_LEGEND_ORDER.slice(0, 5); +const LEGEND_ROW_TWO = PLANET_LEGEND_ORDER.slice(5); +const LEGEND_RETROGRADE_COPY = "横线=逆行"; + +/** Symbol grid inside one house, in viewBox units of the 400-unit square. */ +const GLYPHS_PER_ROW = 3; +const GLYPH_COLUMN_STEP = 32; +const GLYPH_ROW_STEP = 29; +const RETROGRADE_BAR_HALF_WIDTH = 9; +const RETROGRADE_BAR_DROP = 13; + +const MAX_RENDERED_OCCUPANTS = 6; + export type VedicChartModel = NorthIndianChartModel; interface VedicChartSvgProps { @@ -32,68 +72,223 @@ interface VedicChartSvgProps { highlightHouseNumbers?: ReadonlySet; } -function buildRetrogradeSet(chart: NorthIndianChartModel): Set { - const retrograde = new Set(); +type ChartToken = + | { kind: "planet"; key: PlanetGlyphKey; retrograde: boolean } + | { kind: "ascendant" } + | { kind: "text"; label: string } + | { kind: "overflow"; label: string }; + +/** Bodies the chart's own planet list marks retrograde, keyed by glyph. */ +function retrogradeKeys(chart: NorthIndianChartModel): Set { + const retrograde = new Set(); for (const planet of chart.planets ?? []) { - if (planet.retrograde) retrograde.add(planet.name); + if (!planet.retrograde) continue; + const key = planetGlyphKey(planet.name); + if (key) retrograde.add(key); } return retrograde; } -function occupantLines(house: NorthIndianChartModel["houses"][number], chart: NorthIndianChartModel): string[] { - const retrograde = buildRetrogradeSet(chart); - return house.occupants.map((name) => { - if (name.includes("逆") || name.includes("°")) return name; - return retrograde.has(name) ? `${name}逆` : name; +/** + * House occupants as drawable tokens. + * + * `occupantGlyphs` is the structured path: `toNorthIndianChart` knows the body + * and its direction before it ever formats a label, so the chart page and the + * markdown chart blocks never guess. The rectification board and the personal + * report hand over display text only — their house tables come from the engine + * and from golden-fixture-shaped contracts that may not be invented here — so + * those two routes are read back with `parseChartOccupant`. + */ +function houseTokens( + house: NorthIndianChartModel["houses"][number], + retrograde: ReadonlySet, +): ChartToken[] { + if (house.occupantGlyphs) { + return house.occupantGlyphs.map((occupant) => ({ + kind: "planet" as const, + key: occupant.key, + retrograde: occupant.retrograde || retrograde.has(occupant.key), + })); + } + return house.occupants.map((label) => { + const occupant = parseChartOccupant(label); + if (occupant.kind === "planet") { + return { + kind: "planet" as const, + key: occupant.key, + retrograde: occupant.retrograde || retrograde.has(occupant.key), + }; + } + if (occupant.kind === "ascendant") return { kind: "ascendant" as const }; + return { kind: "text" as const, label: occupant.label }; }); } -const MAX_RENDERED_OCCUPANTS = 6; - -function visibleOccupantLines(lines: string[]): string[] { - if (lines.length <= MAX_RENDERED_OCCUPANTS) return lines; - return [...lines.slice(0, MAX_RENDERED_OCCUPANTS - 1), `+${lines.length - (MAX_RENDERED_OCCUPANTS - 1)}`]; +/** + * House 1 of a North Indian chart is the ascendant by construction, so the mark + * is drawn from geometry rather than from data. An explicit ascendant occupant + * (the report bundle can carry one) collapses into the same mark. + */ +function withAscendantMark(houseNumber: number, tokens: ChartToken[]): ChartToken[] { + const rest = tokens.filter((token) => token.kind !== "ascendant"); + return houseNumber === 1 ? [{ kind: "ascendant" }, ...rest] : rest; } -function OccupantStack({ - lines, - origin, -}: { - lines: string[]; - origin: { x: number; y: number }; -}) { - const visible = visibleOccupantLines(lines); - const fontSize = visible.length > 4 ? 8 : 9; - const rowStep = visible.length > 4 ? 10 : 12; - const startY = origin.y - ((visible.length - 1) * rowStep) / 2; +/** + * The overflow budget counts occupants, not the ascendant mark: "As" is drawn + * from the chart's geometry, and letting it push a real body into `+N` would + * make house 1 show one body fewer than every other house. + */ +function visibleTokens(tokens: ChartToken[]): ChartToken[] { + const ascendant = tokens.filter((token) => token.kind === "ascendant"); + const occupants = tokens.filter((token) => token.kind !== "ascendant"); + if (occupants.length <= MAX_RENDERED_OCCUPANTS) return [...ascendant, ...occupants]; + return [ + ...ascendant, + ...occupants.slice(0, MAX_RENDERED_OCCUPANTS - 1), + { kind: "overflow", label: `+${occupants.length - (MAX_RENDERED_OCCUPANTS - 1)}` }, + ]; +} + +function tokenKey(token: ChartToken, index: number): string { + if (token.kind === "planet") return `${token.key}-${index}`; + if (token.kind === "ascendant") return `asc-${index}`; + return `${token.label}-${index}`; +} + +function OccupantGlyph({ token, x, y }: { token: ChartToken; x: number; y: number }) { + if (token.kind === "ascendant") { + return ( + + {ASCENDANT_MARK} + + ); + } + if (token.kind !== "planet") { + return ( + + {token.label} + + ); + } + const color = PLANET_COLOR_CLASS[token.key]; return ( <> - {visible.map((line, index) => ( - - {line} - - ))} + + {PLANET_GLYPH[token.key]} + + {token.retrograde && ( + + )} ); } +/** Up to three symbols per row, centred; a fourth body starts a second row. */ +function OccupantGrid({ tokens, origin }: { tokens: ChartToken[]; origin: { x: number; y: number } }) { + const visible = visibleTokens(tokens); + const rows = Math.ceil(visible.length / GLYPHS_PER_ROW); + return ( + <> + {visible.map((token, index) => { + const column = index % GLYPHS_PER_ROW; + const row = Math.floor(index / GLYPHS_PER_ROW); + const columnsInRow = row === rows - 1 + ? visible.length - row * GLYPHS_PER_ROW + : GLYPHS_PER_ROW; + return ( + + ); + })} + + ); +} + +/** Nine symbols with their one-character names, plus what the bar under one means. */ +function ChartLegend() { + const row = (keys: readonly PlanetGlyphKey[], y: number) => keys.map((key, index) => ( + + + {PLANET_GLYPH[key]} + + + {PLANET_GLYPH_ZH[key]} + + + )); + return ( + + {row(LEGEND_ROW_ONE, LEGEND_ROW_ONE_Y)} + {row(LEGEND_ROW_TWO, LEGEND_ROW_TWO_Y)} + + {LEGEND_RETROGRADE_COPY} + + + ); +} + export function VedicChartSvg({ chart, ariaLabel, highlightHouseNumbers }: VedicChartSvgProps) { const chartId = useId().replace(/:/g, ""); - const linesByHouse = new Map(); + const retrograde = retrogradeKeys(chart); + const tokensByHouse = new Map(); for (const house of chart.houses) { - linesByHouse.set(house.houseNumber, occupantLines(house, chart)); + tokensByHouse.set(house.houseNumber, houseTokens(house, retrograde)); } - const hasRetrogradeMarker = [...linesByHouse.values()].some((lines) => lines.some((line) => line.includes("逆"))); + // An empty grid stays empty: the ascendant mark and the legend are only worth + // drawing once the chart actually carries bodies. + const hasPlanets = [...tokensByHouse.values()] + .some((tokens) => tokens.some((token) => token.kind === "planet")); + if (hasPlanets) { + tokensByHouse.set(1, withAscendantMark(1, tokensByHouse.get(1) ?? [])); + } + const viewBoxHeight = CHART_SIZE + (hasPlanets ? LEGEND_BAND_HEIGHT : 0); return ( { const polygon = HOUSE_POLYGONS[houseNumber]; const house = chart.houses.find((item) => item.houseNumber === houseNumber); - const lines = house ? linesByHouse.get(houseNumber) ?? [] : []; - const centroid = polygonCentroid(polygon); + const tokens = house ? tokensByHouse.get(houseNumber) ?? [] : []; const numberAnchor = signNumberAnchor(houseNumber); const ordinal = house ? signOrdinal(house.sign) : null; return ( @@ -133,22 +327,12 @@ export function VedicChartSvg({ chart, ariaLabel, highlightHouseNumbers }: Vedic )} - + ); })} - {hasRetrogradeMarker && ( - - “逆”=逆行 - - )} + {hasPlanets && } ); } diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index ca16a0fa..80d6ec5c 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { PLANET_GLYPH_KEYS } from "./planet-glyphs.ts"; + const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); export const chartViewUnavailableLayerSchema = z.object({ @@ -13,6 +15,14 @@ export const northIndianChartSchema = z.object({ houseNumber: z.number().int().min(1).max(12), sign: z.string().min(1), occupants: z.array(z.string()), + // The chart draws symbols, so it needs the body and its direction rather + // than the formatted label. Optional because the schema also has to accept + // house tables that only ever carried display text. + occupantGlyphs: z.array(z.object({ + key: z.enum(PLANET_GLYPH_KEYS), + retrograde: z.boolean(), + degree: z.number().gte(0).lt(30), + })).optional(), })).length(12), planets: z.array(z.object({ name: z.string().min(1), @@ -37,6 +47,24 @@ export const chartViewPlanetRowSchema = z.object({ symbol: z.string().nullable(), }); +/** + * The ascendant is a chart point, not a body: the engine returns its sign and + * degree but no nakshatra, so those three fields are nullable rather than + * filled in with a plausible value. + */ +export const chartViewAscendantRowSchema = z.object({ + label: z.string().min(1), + abbr: z.string().min(1), + sign: z.string().min(1), + signLabel: z.string().min(1), + degreeInSign: z.number(), + house: z.literal(1), + retrograde: z.literal(false), + nakshatra: z.string().min(1).nullable(), + pada: z.number().int().min(1).max(4).nullable(), + nakshatraLord: z.string().min(1).nullable(), +}); + export const chartViewVargaSchema = z.object({ id: z.string().regex(/^D\d{1,3}$/), title: z.string().min(1), @@ -164,6 +192,7 @@ export const chartViewOkSchema = z.object({ coordinateSystem: z.literal("sidereal"), boundary: z.string().min(1), vargas: z.array(chartViewVargaSchema).min(1), + ascendant: chartViewAscendantRowSchema.nullable(), planets: z.array(chartViewPlanetRowSchema).min(1), }), dasha: z.object({ @@ -192,6 +221,7 @@ export type ChartViewQizhengOk = z.infer; export type ChartViewVarga = z.infer; export type ChartViewPlanetRow = z.infer; export type NorthIndianChart = z.infer; +export type ChartViewAscendantRow = z.infer; export const CHART_VIEW_TABS = [ { id: "vedic", label: "星盘" }, diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index 22e6e9c5..707a1196 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -300,6 +300,32 @@ function planetRows(chart: Record, vargaPacket: Record) { + const ascendant = record(chart.ascendant); + const sign = text(ascendant?.sign); + if (!ascendant || !sign) return null; + const nakshatra = text(ascendant.nakshatra); + const pada = finite(ascendant.nakshatra_pada); + const lord = text(ascendant.nakshatra_lord); + return { + label: "上升", + abbr: "As", + sign, + signLabel: signZh(sign), + degreeInSign: planetDegreeInSign(ascendant), + house: 1 as const, + retrograde: false as const, + nakshatra, + pada: pada === null ? null : Math.min(4, Math.max(1, Math.trunc(pada))), + nakshatraLord: lord ? planetZh(lord) : null, + }; +} + function vimshottariTrack(chart: Record, asOf: string) { const dasha = record(chart.dasha) ?? {}; const rawPeriods = Array.isArray(dasha.periods) ? dasha.periods : []; @@ -651,6 +677,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { coordinateSystem: "sidereal" as const, boundary: COORDINATE_BOUNDARY.vedic, vargas, + ascendant: ascendantRow(chart), planets: planetRows(chart, varga), }, dasha: { diff --git a/frontend/src/lib/north-indian-chart-geometry.ts b/frontend/src/lib/north-indian-chart-geometry.ts index 82731394..5c53ee74 100644 --- a/frontend/src/lib/north-indian-chart-geometry.ts +++ b/frontend/src/lib/north-indian-chart-geometry.ts @@ -104,6 +104,24 @@ function innerVertex(points: readonly Point[]): Point { return points.reduce((best, point) => (distance(point, CENTER) < distance(best, CENTER) ? point : best)); } +/** + * Where a house's planet symbols sit. The raw centroid crowds the sign number + * in the triangular houses, so the group is nudged away from the vertex the + * number hangs on — the centre for the four diamonds, the right angle for the + * eight triangles. + */ +export function occupantAnchor(houseNumber: number): Point { + const polygon = HOUSE_POLYGONS[houseNumber]; + const centroid = polygonCentroid(polygon); + const isDiamond = polygon.length === 4; + const anchorVertex = isDiamond ? innerVertex(polygon) : rightAngleVertex(polygon); + const push = isDiamond ? 0.05 : 0.18; + return { + x: centroid.x + (centroid.x - anchorVertex.x) * push, + y: centroid.y + (centroid.y - anchorVertex.y) * push, + }; +} + export function signNumberAnchor(houseNumber: number): Point { const polygon = HOUSE_POLYGONS[houseNumber]; const centroid = polygonCentroid(polygon); diff --git a/frontend/src/lib/planet-glyphs.ts b/frontend/src/lib/planet-glyphs.ts new file mode 100644 index 00000000..0704317c --- /dev/null +++ b/frontend/src/lib/planet-glyphs.ts @@ -0,0 +1,186 @@ +/** + * Planet symbols for the North Indian chart and the chart page planet table. + * + * The chart used to print display text inside each house ("水 19°", "罗逆 29°"), + * which stacked three labels on top of each other whenever a house held three + * planets. The chart now carries symbols only; degrees, nakshatra and direction + * live in the planet table under the chart. + * + * Colour is an identification aid, not a verdict: nothing here says benefic or + * malefic. Rahu and Ketu deliberately share one neutral grey. + * + * ♀ and ♂ are followed by U+FE0E (VARIATION SELECTOR-15, text presentation). + * Without it iOS renders both as colour emoji. + */ + +export const PLANET_GLYPH_KEYS = [ + "sun", + "moon", + "mars", + "mercury", + "jupiter", + "venus", + "saturn", + "rahu", + "ketu", +] as const; + +export type PlanetGlyphKey = (typeof PLANET_GLYPH_KEYS)[number]; + +const TEXT_PRESENTATION = "\uFE0E"; + +export const PLANET_GLYPH: Readonly> = { + sun: "☉", + moon: "☽", + mars: `♂${TEXT_PRESENTATION}`, + mercury: "☿", + jupiter: "♃", + venus: `♀${TEXT_PRESENTATION}`, + saturn: "♄", + rahu: "☊", + ketu: "☋", +}; + +/** One-character Chinese name, as used in the chart legend. */ +export const PLANET_GLYPH_ZH: Readonly> = { + sun: "日", + moon: "月", + mars: "火", + mercury: "水", + jupiter: "木", + venus: "金", + saturn: "土", + rahu: "罗", + ketu: "计", +}; + +/** Full Chinese name, as used in the planet table. */ +export const PLANET_NAME_ZH: Readonly> = { + sun: "太阳", + moon: "月亮", + mars: "火星", + mercury: "水星", + jupiter: "木星", + venus: "金星", + saturn: "土星", + rahu: "北交点", + ketu: "南交点", +}; + +export const PLANET_ABBR: Readonly> = { + sun: "Su", + moon: "Mo", + mars: "Ma", + mercury: "Me", + jupiter: "Ju", + venus: "Ve", + saturn: "Sa", + rahu: "Ra", + ketu: "Ke", +}; + +/** + * Colour modifier per body; both nodes share one. The colours themselves are + * `--color-planet-*` in globals.css, one value per theme. + * + * A class rather than an inline `style`: the personal report's markup contract + * forbids a `style=` attribute anywhere in the rendered document, and that rule + * is load-bearing (it is what keeps agent-authored content from carrying its + * own CSS), so the chart may not be the one exception. + */ +export const PLANET_COLOR_CLASS: Readonly> = { + sun: "is-sun", + moon: "is-moon", + mars: "is-mars", + mercury: "is-mercury", + jupiter: "is-jupiter", + venus: "is-venus", + saturn: "is-saturn", + rahu: "is-node", + ketu: "is-node", +}; + +export const ASCENDANT_COLOR_CLASS = "is-ascendant"; +/** The North Indian first house is always the ascendant, so the mark needs no data. */ +export const ASCENDANT_MARK = "As"; +export const ASCENDANT_NAME_ZH = "上升"; + +/** Chinese names the engines emit, keyed by their first character. */ +const ZH_FIRST_CHARACTER: Readonly> = { + 日: "sun", + 太: "sun", // 太阳 + 月: "moon", + 火: "mars", + 水: "mercury", + 木: "jupiter", + 金: "venus", + 土: "saturn", + 罗: "rahu", // 罗睺 + 计: "ketu", // 计都 +}; + +const EN_NAMES: Readonly> = { + sun: "sun", + moon: "moon", + mars: "mars", + mercury: "mercury", + jupiter: "jupiter", + venus: "venus", + saturn: "saturn", + rahu: "rahu", + ketu: "ketu", +}; + +const ASCENDANT_NAMES = new Set(["as", "asc", "ascendant", "lagna"]); +/** 上升 / 上升点 / 上升星座 — the report's own wording for the same point. */ +const ASCENDANT_ZH_PREFIX = "上升"; + +/** + * Maps a label to a glyph key. Accepts every spelling the three call sites can + * produce: the report chart block's one-character names ("水", "罗"), the + * rectification house table's full Chinese names ("水星", "罗睺"), and the + * report evidence bundle's English celestial names ("Mercury", "Rahu"). + */ +export function planetGlyphKey(name: string): PlanetGlyphKey | null { + const trimmed = name.trim(); + if (!trimmed) return null; + const latin = trimmed.match(/^[A-Za-z]+/); + if (latin) return EN_NAMES[latin[0].toLowerCase()] ?? null; + return ZH_FIRST_CHARACTER[trimmed[0]!] ?? null; +} + +export type ChartOccupant = + | { kind: "planet"; key: PlanetGlyphKey; retrograde: boolean; label: string } + | { kind: "ascendant"; label: string } + | { kind: "text"; label: string }; + +/** + * Reads one occupant string. The rectification board and the personal report + * both receive their house tables as engine/agent display text and have no + * structured planet list to join against — their contracts are golden-fixture + * shaped and may not be invented here — so those two routes are parsed. + * `toNorthIndianChart` instead attaches `occupantGlyphs` and never reaches this. + */ +export function parseChartOccupant(label: string): ChartOccupant { + const trimmed = label.trim(); + if (!trimmed) return { kind: "text", label }; + if (ASCENDANT_NAMES.has(trimmed.toLowerCase()) || trimmed.startsWith(ASCENDANT_ZH_PREFIX)) { + return { kind: "ascendant", label: trimmed }; + } + const key = planetGlyphKey(trimmed); + if (!key) return { kind: "text", label: trimmed }; + return { kind: "planet", key, retrograde: trimmed.includes("逆"), label: trimmed }; +} + +/** Reading order for the chart legend: 日 月 水 金 火 木 土 罗 计. */ +export const PLANET_LEGEND_ORDER = [ + "sun", + "moon", + "mercury", + "venus", + "mars", + "jupiter", + "saturn", + "rahu", + "ketu", +] as const satisfies readonly PlanetGlyphKey[]; diff --git a/frontend/src/lib/report-chart-block.ts b/frontend/src/lib/report-chart-block.ts index ea5bdaa8..36b7100a 100644 --- a/frontend/src/lib/report-chart-block.ts +++ b/frontend/src/lib/report-chart-block.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { PLANET_GLYPH_KEYS, type PlanetGlyphKey } from "./planet-glyphs"; + const SIGNS = [ "Aries", "Taurus", @@ -99,8 +101,21 @@ export const reportChartBlockSchema = z.strictObject({ export type ReportChartBlock = z.infer; +/** + * `occupants` stays the display string — it is what `aria-label`, the house + * `
` table and screen readers read out. `occupantGlyphs` is the + * structured twin the chart draws from, so the renderer never has to read a + * body or its direction back out of formatted text. Routes whose house table + * arrives as engine/agent text (the rectification board, the personal report + * document) omit it and are parsed instead. + */ export type NorthIndianChartModel = { - houses: { houseNumber: number; sign: string; occupants: string[] }[]; + houses: { + houseNumber: number; + sign: string; + occupants: string[]; + occupantGlyphs?: { key: PlanetGlyphKey; retrograde: boolean; degree: number }[]; + }[]; planets?: { name: string; retrograde: boolean }[]; }; @@ -137,21 +152,45 @@ export function chartAriaLabel(id: ReportChartBlock["id"]): string { return id === "MOON" ? "月亮参考盘" : `${id} 北印度盘`; } +const PLANET_GLYPH_KEY: Record<(typeof PLANETS)[number], PlanetGlyphKey> = { + Sun: "sun", + Moon: "moon", + Mars: "mars", + Mercury: "mercury", + Jupiter: "jupiter", + Venus: "venus", + Saturn: "saturn", + Rahu: "rahu", + Ketu: "ketu", +}; + +export function isPlanetGlyphKey(value: string): value is PlanetGlyphKey { + return (PLANET_GLYPH_KEYS as readonly string[]).includes(value); +} + export function toNorthIndianChart(block: ReportChartBlock): NorthIndianChartModel { const ascIndex = SIGNS.indexOf(block.ascendant.sign); const occupantsByHouse: string[][] = Array.from({ length: 12 }, () => []); + const glyphsByHouse: NonNullable[] = + Array.from({ length: 12 }, () => []); for (const planet of block.planets) { const signIndex = SIGNS.indexOf(planet.sign); const houseNumber = ((signIndex - ascIndex + 12) % 12) + 1; occupantsByHouse[houseNumber - 1].push( planetDisplayLabel(planet.name, planet.degree, planet.retrograde), ); + glyphsByHouse[houseNumber - 1].push({ + key: PLANET_GLYPH_KEY[planet.name], + retrograde: planet.retrograde, + degree: planet.degree, + }); } return { houses: Array.from({ length: 12 }, (_, index) => ({ houseNumber: index + 1, sign: SIGNS[(ascIndex + index + 12) % 12], occupants: occupantsByHouse[index], + occupantGlyphs: glyphsByHouse[index], })), planets: block.planets.map((planet) => ({ name: PLANET_LABEL[planet.name], diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index bcced104..c947a14b 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -203,7 +203,19 @@ test("the parameters are one key/value table, not a per-breakpoint duplicate", ( } assert.match(cssDeclarations(".chart-page-tab", globalStyles), /min-height:\s*44px/); assert.match(cssDeclarations(".chart-page-chip", globalStyles), /min-height:\s*44px/); - assert.match(globalStyles, /\.chart-page-vedic-stage \.personal-report-chart-svg text \{ font-size: 16px; \}/); + // 原值:`.chart-page-vedic-stage .personal-report-chart-svg text { font-size: 16px; }` + // —— 一条 `text` 通选,把盘内所有文字统一提到 16 viewBox 单位。 + // 新值:只提小字(宫位序号 16、As 15、图例 14/16),并新增一条反向断言:这个 + // stage 下不得再出现 `text {` 通选规则。 + // 原因:盘内文字现在不只有一种字号——行星符号本身已经按窄盘宽度定在 22 单位。 + // 通选规则会把符号一起压回 16,正是本轮要避免的(DESIGN §15 的原意是 + // 「小字在 ~318px 盘宽下不低于 12px」,不是「所有字一样大」)。断言主语 + // (宫位序号仍是 16)一字未改,新增的 doesNotMatch 比原断言更强。 + assert.match( + globalStyles, + /\.chart-page-vedic-stage \.personal-report-chart-svg \.personal-report-chart-muted \{ font-size: 16px; \}/, + ); + assert.doesNotMatch(globalStyles, /\.chart-page-vedic-stage \.personal-report-chart-svg text \{/); }); test("the planet table scrolls inside its own box instead of widening the page", () => { diff --git a/frontend/tests/chart-planet-glyphs.test.tsx b/frontend/tests/chart-planet-glyphs.test.tsx new file mode 100644 index 00000000..80a83b6d --- /dev/null +++ b/frontend/tests/chart-planet-glyphs.test.tsx @@ -0,0 +1,243 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import test from "node:test"; + +import { VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg.tsx"; +import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx"; +import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts"; +import type { ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { + PLANET_GLYPH, + PLANET_GLYPH_KEYS, + parseChartOccupant, +} from "../src/lib/planet-glyphs.ts"; +import { toNorthIndianChart } from "../src/lib/report-chart-block.ts"; + +Object.assign(globalThis, { React }); + +const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8"); + +/** The two house-table shapes that reach the chart as display text only. */ +function textChart(occupantsByHouse: Record) { + return { + houses: Array.from({ length: 12 }, (_, index) => ({ + houseNumber: index + 1, + sign: "白羊座", + occupants: occupantsByHouse[index + 1] ?? [], + })), + }; +} + +function draw(chart: Parameters[0]["chart"], label = "测试盘"): string { + return renderToStaticMarkup(React.createElement(VedicChartSvg, { chart, ariaLabel: label })); +} + +test("a house of display text renders symbols, and a retrograde body gets a bar instead of 逆", () => { + // The report chart block's own label form, which is what the markdown charts + // and the rectification board's engine house tables look like on the wire. + const markup = draw(textChart({ 5: ["水 19°", "罗逆 29°"] })); + assert.match(markup, /☿/, "水 renders as ☿"); + assert.match(markup, /☊/, "罗 renders as ☊"); + assert.match(markup, /class="personal-report-chart-retrograde is-node"/, "罗逆 gets a bar"); + assert.doesNotMatch(markup, /class="personal-report-chart-retrograde is-mercury"/, "水 is direct"); + assert.doesNotMatch(markup, /°/, "degrees are gone from the chart"); + // 「逆」survives only in the legend line 横线=逆行. + assert.deepEqual(markup.match(/逆/g), ["逆"]); +}); + +test("the rectification board's full Chinese names map to the same symbols", () => { + // scripts/rectification/house_table.py emits 太阳 / 水星 / 罗睺, not 日 / 水 / 罗. + const markup = draw(textChart({ 4: ["太阳", "水星", "罗睺", "计都"] })); + for (const glyph of ["☉", "☿", "☊", "☋"]) assert.match(markup, new RegExp(glyph)); +}); + +test("♀ and ♂ carry U+FE0E so iOS draws them as text, not emoji", () => { + assert.equal(PLANET_GLYPH.venus, "♀︎"); + assert.equal(PLANET_GLYPH.mars, "♂︎"); + const markup = draw(textChart({ 2: ["金 1°", "火 2°"] })); + assert.match(markup, /♀︎/); + assert.match(markup, /♂︎/); +}); + +test("house 1 carries the As mark, because the North Indian first house is the ascendant", () => { + const markup = draw(textChart({ 1: ["日 22°"], 3: ["月 12°"] })); + assert.match(markup, /class="personal-report-chart-asc is-ascendant">AsAs { + const markup = draw(textChart({ 1: ["上升点", "日 22°"] })); + assert.equal((markup.match(/>As { + const empty = draw(textChart({})); + assert.doesNotMatch(empty, /横线=逆行/); + assert.doesNotMatch(empty, />As${PLANET_GLYPH[key]}<`)); + } + for (const name of ["日", "月", "水", "金", "火", "木", "土", "罗", "计"]) { + assert.match(filled, new RegExp(`class="personal-report-chart-legend">${name}<`)); + } +}); + +test("six bodies in one house wrap onto two rows; a seventh becomes +N", () => { + const six = draw(textChart({ 4: ["日 1°", "月 2°", "火 3°", "水 4°", "木 5°", "金 6°"] })); + const ys = [...six.matchAll(/class="personal-report-chart-glyph[^"]*"/g)]; + assert.equal(ys.length, 6, "all six are drawn, none summarised"); + const rows = new Set( + [...six.matchAll(/]*class="personal-report-chart-glyph/g)] + .map((hit) => hit[1]), + ); + assert.equal(rows.size, 2, "three per row, so six bodies occupy two rows"); + + const seven = draw(textChart({ + 4: ["日 1°", "月 2°", "火 3°", "水 4°", "木 5°", "金 6°", "土 7°"], + })); + assert.match(seven, />\+2 { + const chart = toNorthIndianChart({ + version: 1, + id: "D1", + title: "D1", + layout: "north", + ascendant: { sign: "Leo", degree: 12.3 }, + planets: [{ name: "Saturn", sign: "Capricorn", degree: 3.4, retrograde: true }], + }); + const house = chart.houses.find((item) => item.occupantGlyphs?.length); + assert.ok(house, "the occupied house carries occupantGlyphs"); + assert.deepEqual(house.occupantGlyphs, [{ key: "saturn", retrograde: true, degree: 3.4 }]); + // The display string stays: aria-label, the house
table and screen + // readers still read it. + assert.deepEqual(house.occupants, ["土逆 3°"]); +}); + +test("an unknown occupant name is drawn as written rather than dropped", () => { + const markup = draw(textChart({ 6: ["占星体1"] })); + assert.match(markup, /class="personal-report-chart-occupant-text">占星体1 { + assert.deepEqual(parseChartOccupant("水 19°"), { + kind: "planet", key: "mercury", retrograde: false, label: "水 19°", + }); + assert.deepEqual(parseChartOccupant("罗逆 29°"), { + kind: "planet", key: "rahu", retrograde: true, label: "罗逆 29°", + }); + assert.equal(parseChartOccupant("太阳").kind, "planet"); + assert.equal(parseChartOccupant("Mercury").kind, "planet"); + assert.equal(parseChartOccupant("Mars").kind, "planet"); + assert.equal(parseChartOccupant("Lagna").kind, "ascendant"); + assert.equal(parseChartOccupant("上升").kind, "ascendant"); + assert.equal(parseChartOccupant("占星体1").kind, "text"); +}); + +test("every planet colour is defined once in :root and once in each dark block", () => { + const tokens = [ + "sun", "moon", "mars", "mercury", "jupiter", "venus", "saturn", "node", "ascendant", + ].map((name) => `--color-planet-${name}`); + const block = (opener: string) => { + const start = globalStyles.indexOf(opener); + assert.notEqual(start, -1, `missing block: ${opener}`); + return globalStyles.slice(start, globalStyles.indexOf("\n}", start)); + }; + const blocks = { + light: block(":root {\n color-scheme: light;"), + preferred: block(':root:not([data-theme="light"]) {'), + pinned: block(':root[data-theme="dark"] {'), + }; + for (const [name, body] of Object.entries(blocks)) { + for (const token of tokens) { + const hits = [...body.matchAll(new RegExp(`${token}:\\s*(#[0-9a-f]{6});`, "gi"))]; + assert.equal(hits.length, 1, `${token} must be declared exactly once in the ${name} block`); + } + } + // A colour that only exists in one theme is unreadable in the other. + for (const token of tokens) { + assert.notEqual( + blocks.light.match(new RegExp(`${token}:\\s*(#[0-9a-f]{6});`, "i"))![1].toLowerCase(), + blocks.pinned.match(new RegExp(`${token}:\\s*(#[0-9a-f]{6});`, "i"))![1].toLowerCase(), + `${token} must be lifted for the dark ground, not reused`, + ); + } +}); + +test("the symbol font is vendored, not fetched, and the stack falls through to system faces", () => { + assert.match(layout, /src: "\.\/fonts\/NotoSansSymbols-planets\.woff2"/); + assert.match(layout, /variable: "--font-planet-glyphs"/); + assert.doesNotMatch(layout, /from ["']next\/font\/google["']/); + const font = readFileSync(new URL("../src/app/fonts/NotoSansSymbols-planets.woff2", import.meta.url)); + assert.equal(Buffer.from(font.subarray(0, 4)).toString(), "wOF2"); + assert.match( + globalStyles, + /font-family: var\(--font-planet-glyphs\), "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", serif;/, + ); + // The licence and provenance live beside the file, as they do for Inter. + const source = readFileSync(new URL("../src/app/fonts/SOURCE.txt", import.meta.url), "utf8"); + assert.match(source, /NotoSansSymbols-planets\.woff2/); + assert.match(source, /Open Font License/); +}); + +const profile = { + name: "示例", + date: "1990-06-15", + time: "12:00", + placeLabel: "北京", + latitude: 39.9042, + longitude: 116.4074, + timezoneOffset: 8, + timezoneId: "Asia/Shanghai", + ayanamsa: "raman", + birthTimeStatus: "confirmed", +}; + +const golden = JSON.parse( + readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"), +) as { chart: Record; varga_full: Record; chara: Record }; + +async function okView(): Promise { + const result = await assembleChartView({ + userId: "user-1", + profile, + asOf: "2026-09-15", + layers: CHART_VIEW_LAYERS, + postEngine: async (path) => { + if (path === "/api/chart") return { status: "ok", payload: golden.chart }; + if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full }; + if (path === "/api/dasha/chara") return { status: "ok", payload: golden.chara }; + return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + }, + }); + assert.equal(result.body.status, "ok"); + return result.body as ChartViewOk; +} + +test("the chart page planet table carries the columns the chart stopped drawing", async () => { + const view = await okView(); + const markup = renderToStaticMarkup( + React.createElement(ChartVedicTab, { view, vargaId: "D1", onSelectVarga: () => {} }), + ); + const headers = [...markup.matchAll(/([^<]*)<\/th>/g)].map((hit) => hit[1]); + assert.deepEqual(headers, ["行星", "黄道经度", "速度", "宫位", "星宿", "宿主", "pada", "状态"]); + // The ascendant is a row, and it is the first one. + assert.match(markup, /As<\/span>/); + assert.match(markup, /上升/); + // Degrees keep their arc-minutes: 29°59′ and 0°01′ are a sign apart. + assert.match(markup, /\d+°\d\d′/); + assert.match(markup, /class="chart-page-planet-glyph is-sun">☉<\/span> 太阳/); + assert.match(markup, /class="chart-page-planet-abbr">Su { diff --git a/frontend/tests/site-style-isolation-contract.test.ts b/frontend/tests/site-style-isolation-contract.test.ts index 1c4940e6..e4dc034c 100644 --- a/frontend/tests/site-style-isolation-contract.test.ts +++ b/frontend/tests/site-style-isolation-contract.test.ts @@ -17,7 +17,13 @@ test("Inter is loaded through next/font and applied as a CSS variable", () => { assert.match(layout, /src: "\.\/fonts\/InterVariable-latin\.woff2"/); assert.match(layout, /display: "swap"/); assert.match(layout, /variable: "--font-inter"/); - assert.match(layout, /className=\{inter\.variable\}/); + // 原值:/className=\{inter\.variable\}/ —— 上只挂一个字体变量。 + // 新值:/className=\{`\$\{inter\.variable\}[^`]*`\}/ —— 允许同一个模板串里再挂别的 + // 字体变量,但 inter.variable 必须仍是第一个。 + // 原因:本轮新增了 vendored 的 9 字形行星符号子集(--font-planet-glyphs),两个 + // next/font 变量必须挂在同一个 上。断言主语(Inter 以 CSS 变量形式 + // 挂在根元素)没变,只是不再要求 className 只有它一个。 + assert.match(layout, /className=\{`\$\{inter\.variable\}[^`]*`\}/); assert.match(readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"), /var\(--font-inter, Inter\)/); assert.match( readFileSync(new URL("../src/components/admin/admin-app.tsx", import.meta.url), "utf8"),