Chart page layout cleanup (issue #114, round 3, BUG-1465)
Independent Staging Quality Gate / validate (push) Successful in 19m20s
Independent Staging Quality Gate / publish (push) Successful in 4m47s

North board: each house gets fixed slots for its sign name, bodies, tags
and transit glyphs, so names no longer pile up at the centre and corners
and right-hand bodies stay inside the frame. Card body grid column is
minmax(0, 1fr), so the board no longer overflows the card on phones.

Board card keeps two control rows (type, one layer row incl. point
layers); north/south moves into the card header; the duplicate caption
strip is gone. House themes list occupied houses; antardashas sit in one
row; glance grid aligned; date field no longer full width; the time tab
shows only Chara/Yogini below the Vimshottari scroll.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
This commit is contained in:
Jesse_Chen
2026-10-27 16:30:05 +08:00
co-authored by Claude Opus 5.5
parent c098edf2bb
commit 43c5a81a50
13 changed files with 277 additions and 117 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-10-27 — 星盘页重新排整齐(issue #114 第三轮)
- 北印度盘:每一宫的星座名移到这一宫最宽的外侧,不再在盘中心和交叉点挤成一团;右边的星不再伸出盘外。手机上整张盘完整显示在卡片里。
- 盘卡只剩两排:盘型,和一排图层(无图层、八宫点、宫力、行运、虚星、特殊点、映射,一次一个)。北印度 / 南印度移到卡片标题右边。盘上方那条重复的说明条去掉。
- 宫位主题只列有星的宫。大运卡的分运排成一行。命盘速览六格对齐。
- 时运流年:日期框和上月、下月等按钮排在一行;最下面只放 Chara / Yogini,不再重复一遍 Vimshottari。
- Skill 版本不变(BUG-1465)。
## 2026-10-27 — 星盘页加数据表和标点图层(issue #114 第二轮)
- 排盘底册多了「数据表」:行星、虚星、特殊点、映射、星力明细、敌友六张。手机上第一列固定,其余左右滑。
+16
View File
@@ -19963,3 +19963,19 @@
- 相关记录:`4d63475caf`。issue #115。
- 复发自:无
- 修复版本:分支 `codex/home-crash-saved-people-20261027`。
## BUG-1465 | 星盘页北印度盘星座名挤成一团、右侧星体出界
- 状态:resolved
- 首次发现:2026-10-27(issue #114 产品评论「完全就一个字乱」)
- 最近更新:2026-10-27
- 影响面:星盘页与对话右栏的北印度盘(`ChartBoardSvg`,默认盘式),以及手机上的整张盘卡。报告和校正用的 `VedicChartSvg` 不受影响。
- 用户现象:北印度盘中心叠着「升 狮子 / 天蝎 金牛 水瓶」四个星座名,左右上下的对角交叉点各叠两个;右侧两个三角里的星体和度数伸出盘框被截掉。手机上整张盘比卡片宽,右边一列宫被裁掉。盘卡上方还有三排控件和一条重复说明条。
- 触发条件:任何北印度盘。手机宽度下必现裁切。
- 根因:一、`chart-board-svg.tsx` 用报告盘的 `signNumberAnchor` 放星座名。这个位置是给 1–12 的数字用的,放在每一宫靠内的那个角:四个菱形的内角都是盘中心,八个三角的内角两两重合,换成两个汉字就互相压住。二、`occupantAnchor` 在三角宫里把星体从直角顶点往外推 18%,而直角顶点在内侧,结果是推向盘边,右侧三角里两颗星加度数超出 400 单位。三、盘卡内容是 `display: grid` 没定列宽,隐式列取了盘型条和图层条的最小内容宽,手机上比卡片宽。
- 修复:北印度每一宫写死四个位置(`NORTH_SLOTS`:星座名在外侧最宽处,星体居中,标点与行运各一排;左右三角星体竖排);星体组按度数写在右侧整体左偏;标点胶囊按字数量宽排开;卡片内容网格列 `minmax(0, 1fr)`。同轮把图层与标点并成一排、北南切换移到卡片标题右侧、删掉北印度说明条,宫位主题只列有星的宫,时运流年去掉重复的 Vimshottari 一栏,日期框不再撑满整行。
- 验证:`frontend/tests/chart-board-20261027.test.tsx`「north board: every sign name and body sits inside its own house…」:十二个星座名左右两端都在自己的宫内、两两相距 > 60 单位,九颗星的字和度数都在自己的宫内。换回修复前的 `chart-board-svg.tsx` 该测试失败(「天蝎 stays in house 4」)。本机 `next build` + Chrome 无头截图 1440 与 390 宽对比修复前后。
- 防复发:上面的测试;`frontend/DESIGN.md` §15「北印度格内位置」写明不要再用 `signNumberAnchor` 放汉字。
- 相关记录:issue #114;BUG-1083(手机星盘撑宽,同类:网格/弹性子项最小内容宽)。
- 复发自:无
- 修复版本:分支 `codex/chart-ui-114-20261027` 第三轮。
@@ -51,3 +51,21 @@
| 界面 | 标点图层(虚星 / 特殊点 / 映射)、点的检视、数据表六页、八宫点「升」行 | `chart-board-svg.tsx`、`chart-inspector.tsx`、`chart-deep-reading.tsx`、`use-chart-board.ts` |
验证:快速门 Python 部分(py_compile、审计、pytest)全过,前端一步失败 25 条=基线无 Docker 库测试条数;`next build` 通过,`/` ○ Static,首屏 gzip 699696 → 701085(+0.20%)。`tests/test_chart_view_points_api.py` 8 条(太阳推算点公式、Avayogi = Yogi + 186°40′、综合敌友 = 天生 + 临时、单块失败不连坐、路由薄注册);`chart-board-20261027.test.tsx` 新增 4 条(金样含 `chart_points`,由同一模块对金样星盘实算)。
## 第三轮:整版收拾(2026-10-27 傍晚)
产品 15:58 评论「现在做的太丑了……完全就一个字乱」。本机 `next build` + 临时路由(只在本地,未提交)+ Chrome 无头截 1440 / 390 宽,对照 issue 竞品图逐块找乱源:
| 乱在哪 | 根因 | 改法 |
| --- | --- | --- |
| 北印度盘中心叠四个星座名、交叉点叠两个 | 借用报告盘写数字的内角位置 `signNumberAnchor` | `NORTH_SLOTS` 每宫四个位置(BUG-1465) |
| 右侧三角星体出界 | `occupantAnchor` 在三角里往外推 | 同上;星体组左偏、两侧竖排 |
| 手机盘被裁 | 卡片内容网格隐式列取最小内容宽 | `grid-template-columns: minmax(0, 1fr)` |
| 盘卡三排控件 + 说明条 | 第二轮把标点做成第二组页签 | 图层与标点并成一排;北南切换进标题;删说明条 |
| 南印度标点胶囊重叠 | 固定 30 间距、宽度按字数粗估 | 按字宽量、间隔 3 |
| 体系页签又小又偏 | 分段 `Tabs` 套在下划线行样式里;宽 1100 与两栏 1240 不齐 | `variant="line"`;同宽 |
| 宫位主题十二枚 + 小圆点 | — | 只列有星的宫 |
| 日期框撑满整行 | 全站 `input:not(...)×5` 规则 width 100% | 更具体的选择器 |
| 时运流年底部重复一列 Vimshottari 手风琴 | 复用旧大运页 | `ChartDashaTab secondOnly` |
验证见本轮提交说明与下方数字。
+10 -3
View File
@@ -9,21 +9,21 @@
3. 点盘上的「土」:它所在的宫变浅色描边,实线连到它管的宫,虚线连到它看的宫,其他宫变淡;右边九曜表「土星」那行亮;检视里有位置条、月宿、尊贵九格、六力(数字 + 强/中/弱)、视相、它管的宫、主管大运、此刻行运。
4. 按键盘右方向键,切到下一颗星;按 Esc,全部收起。
5. 点「宫位主题」里的「第 7 宫」:盘上第 7 宫亮,实线连到它的宫主所在的宫;检视写宫主、宫内星、看这一宫的星、宫力、八宫点。
6. 图层依次点「八宫点」「宫力」「叠加行运」,每宫出现数字或行运小字,下面一行灰字说明;切到 D9,图层这一排消失。
6. 图层依次点「八宫点」「宫力」「行运」,每宫出现数字或行运小字,下面一行灰字说明;切到 D9,图层这一排消失。
7. 检视里点「在 D9 定位」,盘换成 D9,同一颗星仍选中。
8. 检视里点「问 AI:这颗星」:跳到对话页,输入框里已经是一句问题,没有发出去,点数没变。
## 深读
9. 时运流年:日期框改成 2035-01-01,三层进度条、九曜行运表一起变;点「上年」「下月」「回到今天」都正常。改日期后点「叠到盘面」,盘上行运小字是那一天的。
10. 人生长卷:点一段大运,第二行换成它的分运;点一段分运,第三行换成子运。下面「双轨对照」里点 Yogini 正常。
10. 人生长卷:点一段大运,第二行换成它的分运;点一段分运,第三行换成子运。下面「第二条运法」里点 Yogini 正常。
11. 能量图谱:六力每星一条和六色分量;宫力柱状图点一根柱子,盘上那一宫亮;八宫点表切「按星座」「三角削减」「两步削减」,数字会变。
12. 命性格局:只列成立的格局;点一条,盘上只留这几颗星的颜色;「未满足 N 条」默认折起。
13. 排盘底册:出生资料表、出生历法(提毗、月宿、星期)、行星名册(有 D9 列)、宫主流向(每宫都有,没有「未提供」)。
## 第二轮:数据表与标点
17. 盘卡第二组切到「虚星」:盘上出现 11 枚小胶囊(烟、毁、晕、虹、彗、时、死、利、阎、古、曼);切「特殊点」「映射」同理,映射是 AL、A2…UL。切到 D9 时这一组消失。
17. 图层那一排切到「虚星」:盘上出现 11 枚小胶囊(烟、毁、晕、虹、彗、时、死、利、阎、古、曼);切「特殊点」「映射」同理,映射是 AL、A2…UL。切到 D9 时这一组消失。
18. 点盘上的「UL」:它所在的宫高亮,有一条虚线连回第 12 宫;检视写「映射」、传统含义、来历。方向键切到 AL。
19. 深读 → 排盘底册 → 数据表:六个页签都有内容;手机上左右滑时第一列不动。点「虚星」表里任一行,盘上那一枚变实心。
20. 星力明细切「位置力」「时间力」「其余四力」,数字会变;敌友切「天生」「临时」「综合」。
@@ -39,3 +39,10 @@
- 任何时候都没有转圈或「正在加载」字样(盘型懒加载的空盘骨架除外)。
- 375px 宽的手机上页面不横向滚动(表格、长卷在自己的框里横滚)。
## 第三轮:排整齐(BUG-1465)
22. 电脑和手机各看一次北印度盘:十二个星座名各在自己那一格里,没有两个叠在一起;所有星和度数都在盘框里;手机上盘的四边都看得到。
23. 盘卡只有两排控件(盘型、图层);北印度 / 南印度在卡片标题右边。开「虚星」再点「八宫点」,虚星胶囊消失、八宫点数字出现(一次一个)。
24. 南印度盘开「映射」:同一格里的 AL、A11 两枚胶囊不重叠。
25. 宫位主题只有有星的宫;时运流年的日期框不再占满整行。
+7 -6
View File
@@ -1040,17 +1040,18 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
产品 2026-10-27 拍板(issue #114 Q1–Q9):配色仍用本站的(Q1 B),只学竞品的圆角白卡;一页铺开(Q2 A);南北印度都做、默认北印度(Q3);西洋盘保留(Q4);数字照给并在旁边写「强 / 中 / 弱」(Q5);未满足的瑜伽收起(Q6);「问 AI」只填输入框(Q7);对话一进来右栏就有盘、右栏可拖宽(Q8 A);时运流年的观测日期任意(Q9)。下文「四个 Tab」「行星表在盘下」「盘里只放彩色行星符号」三条对星盘页不再成立,报告与校正右栏仍用 `VedicChartSvg` 不变。
- **页头**:出生资料一行不变;下面是「印度盘 / 西洋盘」分段 `Tabs`(`variant="default"`),西洋盘页原样保留。印度盘之下一行四枚圆角小签:上升 / 月亮 / 太阳 / 当前大运,点了在盘上定位,再点取消。
- **页头**:出生资料一行不变;下面是「印度盘 / 西洋盘」下划线 `Tabs`(`variant="line"`,10-27 第三轮由分段改回;底线与下面的两栏同宽 1240px),西洋盘页原样保留。印度盘之下一行四枚圆角小签:上升 / 月亮 / 太阳 / 当前大运,点了在盘上定位,再点取消。
- **两栏**(≥1024px,左约 53%、右约 47%;更窄时单列):左列「盘」卡 + 「盘面检视」卡 + 当前盘型自己的表(D1 没有这张,九曜卡就是本命表);右列「九曜」「宫位主题」「Vimshottari 大运」「命盘速览」四张卡。卡片白底(`--color-canvas`)、16px 圆角、一条发丝阴影,标题宋体 16px,不加红色竖条。
- **盘卡**:标题「北印度盘 / 南印度盘」,右侧小字「宫位固定 / 星座固定 · 整宫制」。23 种盘型条照旧。下面一行左边四个图层(无图层 / 八宫点 / 宫力 / 叠加行运,只在本命 D1 出现),右边北印度 / 南印度,两组都是分段 `Tabs`。北印度盘上方一条浅底说明条写「本命盘 D1 / 上升 天秤 / 度数 / 当前大运 · 分运」,选中后改写选中的星或宫;南印度把这三行写在盘中央。南北的选择全站只记一份(`localStorage` `chart_board_style`),默认北。
- **盘卡**(10-27 第三轮收拾,产品说「乱」):标题「北印度盘 / 南印度盘」,标题右侧是北印度 / 南印度小号分段 `Tabs`(原来的「宫位固定 · 整宫制」小字删掉)。卡内只有两排控件:23 种盘型条,下面一排图层「无图层 / 八宫点 / 宫力 / 行运 / 虚星 / 特殊点 / 映射」(只在本命 D1 出现,一次只开一个,手机上这一排横滑不折行)。北印度盘上方原来的浅底说明条删掉(选中内容看检视卡);南印度仍把上升与当前运期写在盘中央。卡内网格列 `minmax(0, 1fr)`,盘与控件不会把卡撑宽(第二轮之前手机上盘右侧被裁)。南北的选择全站只记一份(`localStorage` `chart_board_style`),默认北。
- **盘面**(`ChartBoardSvg`,`components/chart-board/`):星体画一字中文名(日月火水木金土罗计)+ 右上小号度数,逆行在度数后加「逆」(产品签过的原型,取代 2026-09-18 起的彩色符号;颜色仍按 `PLANET_COLOR_CLASS` 区分星体,不表示吉凶)。北印度宫内写星座名,上升宫写「升 天秤」并用 `--color-action`;南印度格内左上星座、右上宫号,上升格左上一道斜线。点星:所在宫 `--color-action-soft` 底 + 描边,它管的宫实线、它看的宫虚线(整宫计,火 4/7/8、木 5/7/9、土 3/10/7、罗计 5/7/9),连到的宫浅底,其余宫字淡到 .28。点宫:实线连宫主所在,虚线连看这一宫的星。
- **标点图层**(10-27 第二轮,产品选「做成图层」):盘卡工具行第二组分段 `Tabs`「不标点 / 虚星 / 特殊点 / 映射」,只在 D1 出现,可与八宫点等数值图层同时开。每个点画成一枚小胶囊(`chart-board-mark`,主色描边、浅底,一到两个字:烟、古、宫升、AL、UL…),一行最多三枚、多了换行;选中的那枚实心。点胶囊 = 选中这个点:所在宫高亮,映射再用虚线连回它来自的宫;检视面板写名称、英文名、类别徽标、星座度数与宫、传统含义、月宿(映射没有)、所在座的主落哪一宫、映射的来历、同一座里的星,底部「问 AI:这个点」。方向键在同一组里切换。数据走懒层 `points`(`/api/chart_points`)。
- **图层**:八宫点在每宫写「N点」,28 及以上 `--color-action`,以下土星色;宫力写引擎宫力数,高于本盘十二宫均值一成偏暖、低一成偏冷;叠加行运把观测日的行运星用土星色小字写在对应星座。图层下一行灰字说明参考线。
- **标点图层**(10-27 第二轮,产品选「做成图层」;第三轮并进图层那一排,不再与数值图层同时开):虚星 / 特殊点 / 映射。每个点画成一枚小胶囊(`chart-board-mark`,主色描边、浅底,一到两个字:烟、古、宫升、AL、UL…),按字数量宽、间隔 3 排开(拉丁字母约 5.6、汉字约 9),一行最多三枚、多了换行;北印度两侧三角里竖排在星体旁边;选中的那枚实心。点胶囊 = 选中这个点:所在宫高亮,映射再用虚线连回它来自的宫;检视面板写名称、英文名、类别徽标、星座度数与宫、传统含义、月宿(映射没有)、所在座的主落哪一宫、映射的来历、同一座里的星,底部「问 AI:这个点」。方向键在同一组里切换。数据走懒层 `points`(`/api/chart_points`)。
- **图层**:八宫点在每宫写「N点」,28 及以上 `--color-action`,以下土星色;宫力写引擎宫力数,高于本盘十二宫均值一成偏暖、低一成偏冷(北印度把这个数接在星座名后面);行运把观测日的行运星用土星色小字写在对应星座。图层下一行灰字说明参考线。
- **北印度格内位置**(10-27 第三轮,BUG-1465):每一宫有固定的四个位置(`NORTH_SLOTS`,400 单位方盘)——星座名在这一宫最宽的外侧(四个菱形在上部、上下三角贴外边、左右三角在上角靠边对齐),星体在中间,标点一排,行运小字一排。左右两侧三角窄,星体竖排一列,标点竖排在旁边。度数写在字的右上方,所以一组星整体左偏;同一行两颗星的间距北印度 2.2 倍字号、南印度 1.9 倍。不要再用 `signNumberAnchor`(那是报告盘写数字用的内角位置,换成汉字就会在中心和对角线上撞成一团)。
- **盘面检视**:空时一句「点盘上的星或宫看详情」+「方向键切换 · Esc 收起」。选星:大字 + 名称 + 位置 + 尊贵 / 逆行 / 六力档徽标;分段写星座内位置(0–30° 细条,D1 画月宿分界刻度)、月宿与足、尊贵度(九格:落陷…入旺)、六力(鲁帕、达标百分比、强中弱、竖线是门槛)、视相、它管的宫、主管大运(当前那段实心)、此刻行运(观测日不是今天时写「当日行运」)。选宫:宫主与落宫、宫内星、看这一宫的星、宫力、八宫点。底部「问 AI:这颗星 / 这一宫」(只填输入框并跳到对话,toast「已填进对话输入框,确认后再发送」)和「在 D9 定位」。右上上一个 / 下一个 / 收起三个图标按钮。
- **强中弱**:六力按达标百分比,≥120% 强、90%~120% 中、<90% 弱;八宫点 ≥28 强、25~27 中、<25 弱;宫力按本盘均值 ±10%。徽标 `chart-band`:强 `--color-success-muted` 底、中灰、弱 `--color-danger-muted` 底。
- **九曜卡**:星体 / 星座 · 度 / 宫 / 月宿 · 足 / 六力 / 尊贵;点行 = 选星,选中行 `--color-action-soft`。**宫位主题**:十二枚圆角小签「第 N 宫 · 主题」,有星的前面一个小圆点。**大运卡**:一排大运小卡,当前那张主色描边,点哪张下面列它的分运小签。**命盘速览**:两列六格(上升、月亮 · 月宿、灵魂星、当前运期带进度条、六力最强、需留意),点一格在盘上定位,点运期滚到深读。
- **九曜卡**:星体 / 星座 · 度 / 宫 / 月宿 · 足 / 六力 / 尊贵;点行 = 选星,选中行 `--color-action-soft`。**宫位主题**:只列有星的宫(同竞品「有星入驻」),圆角小签「第 N 宫 · 主题」;空宫从盘上点。**大运卡**:一排大运小卡,当前那张主色描边,点哪张下面一行列它的分运小签(一行横滑,不折行)。**命盘速览**:两列六格左对齐,格间一条发丝线(上升、月亮 · 月宿、灵魂星、当前运期带进度条、六力最强、需留意),点一格在盘上定位,点运期滚到深读。
- **深读**(`#chart-deep`):四个分段页签「时运流年(带当前大运 · 分运字)/ 能量图谱 / 命性格局(带成立组数)/ 排盘底册」,默认时运流年。
- 时运流年:观测日期(日期框 + 上月 / 下月 / 上年 / 下年 / 回到今天,1000–2999 年任意日);当前运期三层进度条(大运、分运、子运;子运用与分运同一个比例公式,`lib/dasha-span.ts`);九曜行运表(星座、上升起、月亮起、该座八宫点 + 强中弱;表头右侧写土星七年半第几段或未激活);「叠到盘面」;Vimshottari 人生长卷三行等比条(大运 → 点开的分运 → 子运,当前段主色底、观测日一根竖线,620px 以下横滚);最下面「双轨对照」原大运页的 Chara / Yogini 对照照旧。
- 时运流年:观测日期(11.5rem 宽的日期框与上月 / 下月 / 上年 / 下年 / 回到今天同一行,1000–2999 年任意日;全站 `input:not(...)` 规则会把输入框撑满整行,这里用更具体的选择器压住);当前运期三层进度条(大运、分运、子运;子运用与分运同一个比例公式,`lib/dasha-span.ts`);九曜行运表(星座、上升起、月亮起、该座八宫点 + 强中弱;表头右侧写土星七年半第几段或未激活);「叠到盘面」;Vimshottari 人生长卷三行等比条(大运 → 点开的分运 → 子运,当前段主色底、观测日一根竖线,620px 以下横滚);最下面「第二条运法」只放 Chara / Yogini 一栏(`ChartDashaTab secondOnly`,最宽 640px);Vimshottari 上面已经画过,不再重复一列手风琴。
- 能量图谱:行星六力(总量条 + 竖线门槛 + 六段分量彩条,右侧百分比、强中弱、鲁帕);宫位力量柱状图(均值虚线,点柱选宫);八宫点矩阵(按宫位 / 按星座,原始 / 三角削减 / 两步削减,格子按点数深浅,末行合计)。各自「叠到盘面」。
- 命性格局:只列与对话卡同一张闭合清单(五大人物、象狮、落陷取消、日水同宫)里成立的,引擎报了削弱的写「部分满足」;没成立的折叠在「未满足 N 条」里。点一条,切回 D1、盘上只留这几颗星的颜色。底部一句「传统格局,本站未验证,只作参考」。
- 排盘底册:参数键值表 + 边界句(原样);出生历法(提毗、月宿、星期);「数据表」六个分段页签(2026-10-27 第二轮,产品贴竞品五张表):行星(名册含 D9 列 + 宫主流向)/ 虚星(11 个,烟毁晕虹彗 + 六个时段点)/ 特殊点(八个特殊上升 + 瑜伽点、阻碍点)/ 映射(AL、A2~A11、UL)/ 星力明细(总表、位置力、时间力、其余四力,维鲁帕)/ 敌友(天生 / 临时 / 综合,综合五档)。首列固定,其余横滑。点虚星、特殊点、映射的一行 = 在盘上选中这个点(切回 D1),再点取消;点行星或星力行 = 选星。八宫点不另开表,能量图谱的矩阵在原始口径下多一行「升」(上升自己的 BAV,不计入合计);大运、瑜伽也不在数据表里重复(产品 10-27 同意)。
@@ -31,12 +31,17 @@ import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
import { ChartBoardSvg, type ChartBoardHouseNote, type ChartBoardPointMark, type ChartBoardStyle } from "./chart-board-svg";
const LAYERS: ReadonlyArray<readonly [ChartBoardLayer, string]> = [
/** One row: a number layer or a set of point tags, never both at once. */
const LAYERS: ReadonlyArray<readonly [ChartBoardLayer | PointGroup, string]> = [
["none", "无图层"],
["sav", "八宫点"],
["bhava", "宫力"],
["transit", "叠加行运"],
["transit", "行运"],
["upagraha", "虚星"],
["special", "特殊点"],
["arudha", "映射"],
];
const POINT_LAYERS: ReadonlySet<string> = new Set<PointGroup>(["upagraha", "special", "arudha"]);
function houseNotes(view: ChartViewOk, model: BoardModel, layer: ChartBoardLayer): (ChartBoardHouseNote | null)[] | undefined {
if (layer === "sav") {
@@ -137,7 +142,7 @@ export function ChartBoardCard({
layerPending: boolean;
layerFailure?: ChartViewFailureStatus;
dimmedBodies?: ReadonlySet<PlanetGlyphKey>;
/** The observation day's sky for the 叠加行运 layer. */
/** The observation day's sky for the 行运 layer. */
transit: ChartViewTransitOk | null;
transitPending?: boolean;
compact?: boolean;
@@ -158,44 +163,34 @@ export function ChartBoardCard({
<Card className="chart-board-card">
<CardHeader className="chart-board-card-header">
<CardTitle className="chart-board-card-title">{style === "north" ? "北印度盘" : "南印度盘"}</CardTitle>
<CardAction className="chart-board-card-hint">{style === "north" ? "宫位固定" : "星座固定"} · 整宫制</CardAction>
</CardHeader>
<CardContent className="chart-board-card-body">
<ChartTypePicker current={type} onSelect={onType} />
<div className="chart-board-toolbar">
{natal ? (
<Tabs value={layer} onValueChange={(value) => value && onLayer(value as ChartBoardLayer)}>
<TabsList variant="default" aria-label="图层">
{LAYERS.map(([id, label]) => (
<TabsTrigger key={id} value={id} className="chart-board-tab">{label}</TabsTrigger>
))}
</TabsList>
</Tabs>
) : <span />}
{natal && onPointLayer ? (
<Tabs value={pointLayer} onValueChange={(value) => value && onPointLayer(value as PointGroup | "none")}>
<TabsList variant="default" aria-label="标点">
<TabsTrigger value="none" className="chart-board-tab">不标点</TabsTrigger>
<TabsTrigger value="upagraha" className="chart-board-tab">虚星</TabsTrigger>
<TabsTrigger value="special" className="chart-board-tab">特殊点</TabsTrigger>
<TabsTrigger value="arudha" className="chart-board-tab">映射</TabsTrigger>
</TabsList>
</Tabs>
) : null}
<CardAction>
<Tabs value={style} onValueChange={(value) => value && onStyle(value as ChartBoardStyle)}>
<TabsList variant="default" aria-label="盘式">
<TabsList variant="default" aria-label="盘式" className="chart-board-style">
<TabsTrigger value="north" className="chart-board-tab">北印度</TabsTrigger>
<TabsTrigger value="south" className="chart-board-tab">南印度</TabsTrigger>
</TabsList>
</Tabs>
</div>
{style === "north" && caption ? (
<div className="chart-board-caption" aria-live="polite">
<span className="chart-board-caption-kicker">{caption.kicker}</span>
<span className="chart-board-caption-title">{caption.title}</span>
{caption.detail ? <span className="chart-board-caption-detail">{caption.detail}</span> : null}
{"footer" in caption && caption.footer ? <span className="chart-board-caption-detail">{caption.footer}</span> : null}
</div>
</CardAction>
</CardHeader>
<CardContent className="chart-board-card-body">
<ChartTypePicker current={type} onSelect={onType} />
{natal ? (
<Tabs value={pointLayer !== "none" && onPointLayer ? pointLayer : layer} onValueChange={(value) => {
if (!value) return;
if (POINT_LAYERS.has(value)) {
onLayer("none");
onPointLayer?.(value as PointGroup);
} else {
onPointLayer?.("none");
onLayer(value as ChartBoardLayer);
}
}}>
<TabsList variant="default" aria-label="图层" className="chart-board-layers">
{LAYERS.filter(([id]) => onPointLayer || !POINT_LAYERS.has(id)).map(([id, label]) => (
<TabsTrigger key={id} value={id} className="chart-board-tab">{label}</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{source && model ? (
<div
@@ -22,15 +22,7 @@ import {
type BoardSelection,
} from "@/lib/chart-board-model";
import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels";
import {
CHART_SIZE,
HOUSE_POLYGONS,
occupantAnchor,
polygonCentroid,
polygonPoints,
signNumberAnchor,
type Point,
} from "@/lib/north-indian-chart-geometry";
import { CHART_SIZE, HOUSE_POLYGONS, polygonPoints, type Point } from "@/lib/north-indian-chart-geometry";
import { PLANET_COLOR_CLASS, PLANET_GLYPH_ZH, PLANET_NAME_ZH, type PlanetGlyphKey } from "@/lib/planet-glyphs";
export type { ChartBoardStyle } from "@/lib/chart-board-style";
@@ -52,28 +44,84 @@ const SOUTH_CELL: Readonly<Record<number, readonly [number, number]>> = {
};
const CELL = CHART_SIZE / 4;
/**
* Where things go in each North Indian house, on the 400-unit square. The
* sign name sits on the house's outer side, where the house is widest, so no
* two labels meet at a corner; the bodies take the middle; tags and transit
* glyphs keep their own row. The side triangles are narrow, so their bodies
* stack in a column with the tags beside them.
*/
type NorthSlot = {
label: Point;
labelAnchor: "start" | "middle" | "end";
bodies: Point;
/** Bodies per row before wrapping. */
perRow: number;
marks: Point;
/** Tags run along a row, or down a column in the side triangles. */
marksDown: boolean;
transit: Point;
};
function diamond(cx: number, cy: number): NorthSlot {
return {
label: { x: cx, y: cy - 58 }, labelAnchor: "middle",
bodies: { x: cx, y: cy - 4 }, perRow: 3,
marks: { x: cx, y: cy + 36 }, marksDown: false,
transit: { x: cx, y: cy + 60 },
};
}
function edge(cx: number, top: boolean): NorthSlot {
const y = (offset: number) => (top ? offset : CHART_SIZE - offset);
return {
label: { x: cx, y: y(top ? 15 : 10) }, labelAnchor: "middle",
bodies: { x: cx, y: y(top ? 52 : 46) }, perRow: 3,
marks: { x: cx, y: y(top ? 31 : 27) }, marksDown: false,
transit: { x: cx, y: y(top ? 78 : 74) },
};
}
function side(cy: number, left: boolean): NorthSlot {
const x = (offset: number) => (left ? offset : CHART_SIZE - offset);
return {
label: { x: x(6), y: cy - 42 }, labelAnchor: left ? "start" : "end",
bodies: { x: x(left ? 24 : 34), y: cy + 8 }, perRow: 1,
marks: { x: x(left ? 64 : 68), y: cy - 6 }, marksDown: true,
transit: { x: x(left ? 24 : 34), y: cy + 58 },
};
}
const H = CHART_SIZE / 2;
const Q = CHART_SIZE / 4;
const NORTH_SLOTS: Readonly<Record<number, NorthSlot>> = {
1: diamond(H, Q), 4: diamond(Q, H), 7: diamond(H, 3 * Q), 10: diamond(3 * Q, H),
2: edge(Q, true), 12: edge(3 * Q, true), 6: edge(Q, false), 8: edge(3 * Q, false),
3: side(Q, true), 5: side(3 * Q, true), 11: side(Q, false), 9: side(3 * Q, false),
};
type Cell = {
house: number;
sign: number;
shape: { kind: "polygon"; points: readonly Point[] } | { kind: "rect"; x: number; y: number };
/** Where the bodies cluster. */
/** Where the bodies cluster; lines meet the house here too. */
anchor: Point;
/** Where lines meet the house. */
hub: Point;
triangle: boolean;
perRow: number;
/** North only. */
slot: NorthSlot | null;
};
function cellsFor(model: BoardModel, style: ChartBoardStyle): Cell[] {
return model.houses.map((house) => {
if (style === "north") {
const points = HOUSE_POLYGONS[house.house]!;
const slot = NORTH_SLOTS[house.house]!;
return {
house: house.house,
sign: house.sign,
shape: { kind: "polygon" as const, points },
anchor: occupantAnchor(house.house),
hub: polygonCentroid(points),
triangle: points.length === 3,
shape: { kind: "polygon" as const, points: HOUSE_POLYGONS[house.house]! },
anchor: slot.bodies,
perRow: slot.perRow,
slot,
};
}
const [col, row] = SOUTH_CELL[house.sign] ?? [0, 0];
@@ -81,31 +129,41 @@ function cellsFor(model: BoardModel, style: ChartBoardStyle): Cell[] {
house: house.house,
sign: house.sign,
shape: { kind: "rect" as const, x: col * CELL, y: row * CELL },
anchor: { x: col * CELL + CELL / 2, y: row * CELL + CELL / 2 + 4 },
hub: { x: col * CELL + CELL / 2, y: row * CELL + CELL / 2 },
triangle: false,
anchor: { x: col * CELL + CELL / 2, y: row * CELL + CELL / 2 + 2 },
perRow: 3,
slot: null,
};
});
}
function bodyLayout(count: number, anchor: Point, triangle: boolean): { size: number; points: Point[] } {
const size = triangle ? (count > 2 ? 15 : 18) : count > 4 ? 16 : count > 2 ? 19 : 22;
const perRow = count <= 2 ? Math.max(count, 1) : count <= 4 ? 2 : 3;
function bodyLayout(count: number, anchor: Point, maxPerRow: number, roomy: boolean): { size: number; points: Point[] } {
const narrow = maxPerRow === 1;
const size = narrow ? (count > 3 ? 14 : 16) : count > 4 ? 15 : count > 2 ? 17 : count === 2 ? 19 : 21;
const perRow = narrow ? (count > 3 ? 2 : 1) : count <= 3 ? Math.max(count, 1) : count === 4 ? 2 : 3;
const rows = Math.ceil(count / perRow);
const stepX = size * 1.95;
// Each degree is written to the right of its glyph, so the group leans left.
const stepX = size * (roomy ? 2.2 : 1.9);
const stepY = size * 1.3;
const points = Array.from({ length: count }, (_, index) => {
const row = Math.floor(index / perRow);
const inRow = Math.min(perRow, count - row * perRow);
const col = index % perRow;
return {
x: anchor.x + (col - (inRow - 1) / 2) * stepX - size * 0.18,
x: anchor.x + (col - (inRow - 1) / 2) * stepX - size * 0.45,
y: anchor.y + (row - (rows - 1) / 2) * stepY + size * 0.35,
};
});
return { size, points };
}
const MARK_GAP = 3;
/** Tag width at the 9-unit tag font: about 9 per Chinese character, 5.5 per Latin one. */
function markWidth(text: string): number {
const ink = [...text].reduce((sum, char) => sum + (char.charCodeAt(0) > 0x2e80 ? 9 : 5.6), 0);
return Math.max(16, Math.round(ink + 8));
}
function activateOnKey(event: KeyboardEvent<SVGGElement>, run: () => void) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
@@ -148,7 +206,7 @@ export function ChartBoardSvg({
const selectedKey = selection?.kind === "body" ? selection.key : null;
const overlayBySign = new Map<number, PlanetGlyphKey[]>();
for (const item of overlay) overlayBySign.set(item.sign, [...(overlayBySign.get(item.sign) ?? []), item.key]);
const focusHub = relations.focus !== null ? byHouse.get(relations.focus)?.hub : undefined;
const focusHub = relations.focus !== null ? byHouse.get(relations.focus)?.anchor : undefined;
const marksBySign = new Map<number, ChartBoardPointMark[]>();
for (const mark of pointMarks) marksBySign.set(mark.sign, [...(marksBySign.get(mark.sign) ?? []), mark]);
const selectedPoint = selection?.kind === "point" ? selection.key : null;
@@ -199,8 +257,8 @@ export function ChartBoardSvg({
const note = houseNotes?.[cell.house - 1] ?? null;
const signName = signZh(SIGN_ORDER[cell.sign] ?? "");
const isAsc = cell.house === model.ascendantHouse;
const labelAt = style === "north" ? signNumberAnchor(cell.house) : null;
const layout = bodyLayout(house.bodies.length, cell.anchor, cell.triangle);
const slot = cell.slot;
const layout = bodyLayout(house.bodies.length, cell.anchor, cell.perRow, style === "north");
const transit = overlayBySign.get(cell.sign) ?? [];
return (
<g key={`ink-${cell.house}`} className={faded ? "chart-board-ink is-faded" : "chart-board-ink"}>
@@ -219,16 +277,14 @@ export function ChartBoardSvg({
</text>
) : null}
</>
) : labelAt ? (
) : slot ? (
<>
<text className={isAsc ? "chart-board-sign is-asc" : "chart-board-sign"} x={labelAt.x} y={labelAt.y + 3} textAnchor="middle">
<text className={isAsc ? "chart-board-sign is-asc" : "chart-board-sign"} x={slot.label.x} y={slot.label.y + 3} textAnchor={slot.labelAnchor}>
{isAsc ? `升 ${signName}` : signName}
{note ? <tspan className={`chart-board-note is-${note.tone}`} dx={4}>{note.text}</tspan> : null}
</text>
{note ? (
<text className={`chart-board-note is-${note.tone}`} x={cell.hub.x} y={cell.triangle ? cell.hub.y + (cell.anchor.y > cell.hub.y ? -22 : 30) : cell.hub.y + 40} textAnchor="middle">{note.text}</text>
) : null}
{transit.length > 0 ? (
<text className="chart-board-transit" x={cell.hub.x} y={cell.triangle ? cell.hub.y + (cell.anchor.y > cell.hub.y ? -10 : 42) : cell.hub.y - 34} textAnchor="middle">
<text className="chart-board-transit" x={slot.transit.x} y={slot.transit.y} textAnchor="middle">
{transit.map((key) => PLANET_GLYPH_ZH[key]).join(" ")}
</text>
) : null}
@@ -268,15 +324,15 @@ export function ChartBoardSvg({
{focusHub ? (
<g className="chart-board-links" aria-hidden="true">
{relations.lordLinks.map((house) => {
const hub = byHouse.get(house)?.hub;
const hub = byHouse.get(house)?.anchor;
return hub ? <line key={`l-${house}`} className="chart-board-link" x1={focusHub.x} y1={focusHub.y} x2={hub.x} y2={hub.y} /> : null;
})}
{relations.aspectLinks.map((house) => {
const hub = byHouse.get(house)?.hub;
const hub = byHouse.get(house)?.anchor;
return hub ? <line key={`a-${house}`} className="chart-board-link is-aspect" x1={focusHub.x} y1={focusHub.y} x2={hub.x} y2={hub.y} /> : null;
})}
{[...relations.lordLinks, ...relations.aspectLinks].map((house) => {
const hub = byHouse.get(house)?.hub;
const hub = byHouse.get(house)?.anchor;
return hub ? <circle key={`d-${house}`} className="chart-board-link-dot" cx={hub.x} cy={hub.y} r={3} /> : null;
})}
</g>
@@ -286,19 +342,25 @@ export function ChartBoardSvg({
if (marks.length === 0) return null;
// Tags sit in one or two short rows: along the cell's lower edge (South), or
// between the sign label and the bodies (North).
const perRow = style === "south" ? 3 : cell.triangle ? 2 : 3;
const base = style === "south" && cell.shape.kind === "rect"
? { x: cell.shape.x + CELL / 2, y: cell.shape.y + CELL - 26 }
: { x: cell.hub.x, y: cell.triangle ? cell.hub.y + (cell.anchor.y > cell.hub.y ? -30 : 34) : cell.hub.y + 30 };
// South: one or two short rows along the cell's lower edge. North: the slot's own row,
// or a column beside the bodies in the side triangles.
const down = cell.slot?.marksDown ?? false;
const perRow = down ? 1 : 3;
const base = cell.slot
? cell.slot.marks
: cell.shape.kind === "rect" ? { x: cell.shape.x + CELL / 2, y: cell.shape.y + CELL - 26 } : cell.anchor;
const lineCount = Math.ceil(marks.length / perRow);
return (
<g key={`marks-${cell.house}`} className="chart-board-marks">
{marks.map((mark, index) => {
const row = Math.floor(index / perRow);
const inRow = Math.min(perRow, marks.length - row * perRow);
const rowMarks = marks.slice(row * perRow, row * perRow + perRow);
const widths = rowMarks.map((item) => markWidth(item.short));
const total = widths.reduce((sum, value) => sum + value, 0) + MARK_GAP * (widths.length - 1);
const col = index % perRow;
const width = Math.max(18, mark.short.length * 10 + 8);
const x = base.x + (col - (inRow - 1) / 2) * 30;
const y = base.y + row * 15;
const width = widths[col]!;
const x = base.x - total / 2 + widths.slice(0, col).reduce((sum, value) => sum + value + MARK_GAP, 0) + width / 2;
const y = base.y + (down ? (row - (lineCount - 1) / 2) * 16 : row * 15);
const on = selectedPoint === mark.key;
const pick = () => onPointSelect?.(mark.key);
return (
@@ -26,7 +26,7 @@
.chart-board-card-header { align-items: baseline; }
.chart-board-card-title { font-family: var(--font-display); font-size: 16px; font-weight: 600; }
.chart-board-card-hint { font-size: 12px; color: var(--color-ink-tertiary); align-self: center; }
.chart-board-card-body { display: grid; gap: var(--space-2); min-width: 0; }
.chart-board-card-body { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-3); min-width: 0; }
.chart-board-tab { gap: 4px; }
.chart-board-tab small { font-size: 11px; color: var(--color-ink-tertiary); font-weight: 400; }
@@ -38,16 +38,12 @@
.chart-board-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2); }
.chart-board-caption {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--color-canvas-soft);
min-height: 44px;
}
.chart-board-caption-kicker { font-size: 11px; letter-spacing: .12em; color: var(--color-action); }
.chart-board-caption-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
.chart-board-caption-detail { font-size: 12px; color: var(--color-ink-secondary); }
/* One row of layers; it scrolls sideways on a phone instead of wrapping. */
.chart-board-layers { max-width: 100%; overflow-x: auto; justify-content: flex-start; scrollbar-width: none; }
.chart-board-layers::-webkit-scrollbar { display: none; }
.chart-board-layers .chart-board-tab { flex: none; }
.chart-board-style { height: 30px; }
.chart-board-style .chart-board-tab { font-size: 12px; padding-inline: 8px; }
.chart-board-plot { width: 100%; max-width: 560px; margin-inline: auto; aspect-ratio: 1; }
.chart-board-plot.is-compact { max-width: 100%; }
@@ -70,8 +66,8 @@
.chart-board-ink { transition: opacity .2s; }
.chart-board-ink.is-faded { opacity: .28; }
.chart-board-ink text { pointer-events: none; }
.chart-board-sign { font-size: 10px; fill: var(--color-ink-tertiary); }
.chart-board-sign.is-asc { fill: var(--color-action); font-weight: 600; }
.chart-board-sign { font-size: 12px; fill: var(--color-ink-tertiary); }
.chart-board-sign.is-asc { fill: var(--color-action); font-weight: 600; font-size: 13px; }
.chart-board-house { font-size: 11px; fill: var(--color-ink-tertiary); }
.chart-board-house.is-asc { fill: var(--color-action); font-weight: 700; }
.chart-board-note { font-size: 10px; fill: var(--color-ink-secondary); }
@@ -193,7 +189,6 @@
.chart-theme-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chart-theme-chip { border-radius: 999px; height: 32px; font-weight: 400; }
.chart-theme-chip.is-on { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-action); }
.chart-theme-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--color-action); }
.chart-dasha-strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
.chart-dasha-chip { flex: none; flex-direction: column; gap: 0; min-width: 78px; padding: 6px 10px; border-radius: 12px; background: var(--color-canvas-soft); }
@@ -202,10 +197,16 @@
.chart-dasha-chip.is-now { border-color: var(--color-action); }
.chart-dasha-chip.is-now strong { color: var(--color-action); }
.chart-dasha-chip.is-open { background: var(--color-action-soft); }
.chart-dasha-antars { margin-top: var(--space-2); }
.chart-dasha-antars { margin-top: var(--space-2); flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
.chart-dasha-antars .chart-chip { flex: none; }
.chart-glance { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); }
.chart-glance-item { display: grid; justify-items: start; gap: 0; padding: var(--space-2); border-radius: var(--radius-md); text-align: left; white-space: normal; }
.chart-glance { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--space-4); }
.chart-glance-item {
display: grid; justify-items: start; justify-content: stretch; align-content: start; gap: 2px;
width: 100%; padding: var(--space-3) var(--space-1); border-radius: 0; border-top: 1px solid var(--color-border);
text-align: left; white-space: normal;
}
.chart-glance-item:nth-child(-n + 2) { border-top: 0; padding-top: 0; }
.chart-glance-item small { font-size: 12px; color: var(--color-ink-tertiary); font-weight: 400; }
.chart-glance-item strong { font-family: var(--font-display); font-size: 18px; font-weight: 600; }
.chart-glance-item span { font-size: 12px; color: var(--color-ink-secondary); font-weight: 400; }
@@ -229,7 +230,6 @@
.chart-deep-more-trigger[data-panel-open] svg { transform: rotate(180deg); }
.chart-date-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.chart-date-input { width: auto; min-width: 150px; }
.chart-period { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 4px var(--space-2); margin-bottom: var(--space-3); }
.chart-period-level { font-size: 12px; color: var(--color-ink-tertiary); padding-top: 4px; }
@@ -348,3 +348,16 @@
.chart-av-lagna th,
.chart-av-lagna td span { color: var(--color-ink-secondary); }
.chart-board-marks { pointer-events: auto; }
/* 时运 tab: Vimshottari is drawn above; the second track sits alone, at reading width. */
.chart-page-dasha-columns.is-single { grid-template-columns: minmax(0, 1fr); max-width: 640px; }
/* Outweighs the site-wide input rule (globals.css, input:not(...) × 5) that makes every field full width. */
.chart-deep-body .chart-deep-section .chart-date-bar input.chart-date-input[type="date"][aria-label] { flex: none; width: 11.5rem; min-width: 0; min-height: 36px; height: 36px; }
.chart-page-tabs:has(+ .chart-page-panel .chart-workspace) { width: min(1240px, 100%); }
@media (max-width: 640px) {
.chart-deep-tabs { overflow-x: auto; justify-content: flex-start; scrollbar-width: none; }
.chart-deep-tabs .chart-board-tab { flex: none; }
}
/* The type strip inside the board card: same height as the layer row below it. */
.chart-board-card .chart-page-type-bar { margin-bottom: 0; }
.chart-board-card .chart-page-type-strip [data-slot="tabs-trigger"] { min-height: 0; }
@@ -187,7 +187,7 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai
}, [onNeedLayer]);
return (
<div className="chart-deep-body">
<Section title="观测日期" hint="换日期,运期、行运和盘上的行运图层一起变">
<Section title="观测日期">
<div className="chart-date-bar">
<Input
type="date"
@@ -208,7 +208,7 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai
</div>
</Section>
<div className="chart-deep-two">
<Section title="当前运期" hint="进度 = 这一层已走过多少">
<Section title="当前运期">
{now ? (
<>
<PeriodRow level="大运" span={{ lord: now.maha.lord, label: now.maha.label, start: now.maha.start, end: now.maha.end }} day={observe} />
@@ -292,8 +292,9 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai
</div>
</div>
</Section>
<Section title="双轨对照" hint="Vimshottari 与 Chara / Yogini 并排">
<Section title="第二条运法">
<ChartDashaTab
secondOnly
view={view}
pendingLayers={pendingLayers}
layerFailures={layerFailures}
@@ -115,8 +115,9 @@ export function ChartHouseThemesCard({ model, selection, onSelect }: {
<CardContent className="chart-theme-chips">
{HOUSE_THEME_ZH.map((theme, index) => {
const house = index + 1;
// Houses holding a body, as in the reference; the board reaches every house.
if ((model.houses[index]?.bodies.length ?? 0) === 0) return null;
const on = selection?.kind === "house" && selection.house === house;
const occupied = (model.houses[index]?.bodies.length ?? 0) > 0;
return (
<Button
key={house}
@@ -127,7 +128,6 @@ export function ChartHouseThemesCard({ model, selection, onSelect }: {
className={on ? "chart-theme-chip is-on" : "chart-theme-chip"}
onClick={() => onSelect(on ? null : { kind: "house", house })}
>
{occupied ? <span className="chart-theme-dot" aria-label="有星" /> : null}
第{house}宫 · {theme}
</Button>
);
@@ -231,7 +231,10 @@ export function ChartDashaTab({
system: controlledSystem,
initialSystem = "chara",
onSelectSystem,
secondOnly = false,
}: {
/** The chart page's 时运 tab draws Vimshottari itself; it shows only Chara / Yogini here. */
secondOnly?: boolean;
view: ChartViewOk;
pendingLayers?: ReadonlySet<ChartViewLayer>;
layerFailures?: ReadonlyMap<ChartViewLayer, ChartViewFailureStatus>;
@@ -282,8 +285,8 @@ export function ChartDashaTab({
return (
<div className="chart-page-dasha">
<div className="chart-page-dasha-columns">
<Track {...view.dasha.vimshottari} asOf={asOf} />
<div className={secondOnly ? "chart-page-dasha-columns is-single" : "chart-page-dasha-columns"}>
{secondOnly ? null : <Track {...view.dasha.vimshottari} asOf={asOf} />}
{pending || failure ? (
<section className="chart-page-dasha-track">
{switcher}
@@ -323,7 +323,7 @@ export function ChartPageView({
if (value) selectSystem(value as ChartSystem);
}}
>
<TabsList variant="default" aria-label="星盘体系">
<TabsList variant="line" aria-label="星盘体系">
<TabsTrigger value="vedic" disabled={view == null} className="chart-page-tab">
印度盘
{journey?.tabHasDot("vedic") ? <OnboardingDot playKey="tab-vedic" /> : null}
@@ -24,6 +24,8 @@ import { CHART_VIEW_LAYERS, parseTransitDate } from "../src/lib/chart-view-engin
import { mapAshtakavarga, mapBirthCalendar, mapPoints, mapShadbala, mapYogas, shadbalaLevel } from "../src/lib/chart-view-insight.ts";
import { pointSelection, pointsOf, stepPoint } from "../src/lib/chart-board-view.ts";
import { vimshottariSplit } from "../src/lib/dasha-span.ts";
import { HOUSE_POLYGONS, pointInPolygon } from "../src/lib/north-indian-chart-geometry.ts";
import { SIGN_ORDER, signZh } from "../src/lib/chart-view-labels.ts";
Object.assign(globalThis, { React });
@@ -216,6 +218,40 @@ test("north and south boards draw twelve houses, every body, and lines for a sel
}
});
// Issue #114 round 3: the North board wrote every sign name at its house's inner corner, so the
// four kendra names piled up in the centre and the triangle names met at the diagonals; bodies in
// the right-hand triangles were pushed past the frame. Each name and body must sit in its own house.
test("north board: every sign name and body sits inside its own house, and no two names meet", async () => {
const view = await okView();
const model = natalBoard(view);
const markup = renderToStaticMarkup(React.createElement(ChartBoardSvg, { model, style: "north", selection: null, onSelect: () => {}, ariaLabel: "本命盘" }));
const labels = [...markup.matchAll(/<text class="chart-board-sign[^"]*" x="([\d.]+)" y="([\d.]+)" text-anchor="(\w+)">([^<]+)/g)]
.map((match) => ({ x: Number(match[1]), y: Number(match[2]), anchor: match[3]!, text: match[4]!.replace(/^升 /, "") }));
assert.equal(labels.length, 12);
for (const label of labels) {
const house = model.houses.find((item) => signZh(SIGN_ORDER[item.sign] ?? "") === label.text)!;
const width = 12 * label.text.length;
const left = label.anchor === "start" ? label.x : label.anchor === "end" ? label.x - width : label.x - width / 2;
for (const x of [left, left + width]) {
assert.ok(pointInPolygon({ x, y: label.y - 4 }, HOUSE_POLYGONS[house.house]!), `${label.text} stays in house ${house.house}`);
}
}
for (const [index, a] of labels.entries()) {
for (const b of labels.slice(index + 1)) {
assert.ok(Math.hypot(a.x - b.x, a.y - b.y) > 60, `${a.text} and ${b.text} are apart`);
}
}
const bodies = [...markup.matchAll(/aria-label="[^"]*第(\d+)宫"[^>]*><text class="chart-board-glyph" x="([\d.]+)" y="([\d.]+)" font-size="([\d.]+)"/g)];
assert.equal(bodies.length, 9);
for (const [, house, x, y, size] of bodies) {
const polygon = HOUSE_POLYGONS[Number(house)]!;
const s = Number(size);
// The glyph, and the degree written after it.
assert.ok(pointInPolygon({ x: Number(x) - s / 2, y: Number(y) - s / 2 }, polygon), `glyph in house ${house}`);
assert.ok(pointInPolygon({ x: Number(x) + s * 0.55 + 16, y: Number(y) - s / 2 }, polygon), `degree in house ${house}`);
}
});
test("the inspector shows the number with its band, and 问 AI only writes a question", async () => {
const view = await okView();
const source = boardSource(view, "D1")!;