diff --git a/CHANGELOG.md b/CHANGELOG.md index 514e817d..99e62266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-10-27 — 星盘页重新排整齐(issue #114 第三轮) + +- 北印度盘:每一宫的星座名移到这一宫最宽的外侧,不再在盘中心和交叉点挤成一团;右边的星不再伸出盘外。手机上整张盘完整显示在卡片里。 +- 盘卡只剩两排:盘型,和一排图层(无图层、八宫点、宫力、行运、虚星、特殊点、映射,一次一个)。北印度 / 南印度移到卡片标题右边。盘上方那条重复的说明条去掉。 +- 宫位主题只列有星的宫。大运卡的分运排成一行。命盘速览六格对齐。 +- 时运流年:日期框和上月、下月等按钮排在一行;最下面只放 Chara / Yogini,不再重复一遍 Vimshottari。 +- Skill 版本不变(BUG-1465)。 + ## 2026-10-27 — 星盘页加数据表和标点图层(issue #114 第二轮) - 排盘底册多了「数据表」:行星、虚星、特殊点、映射、星力明细、敌友六张。手机上第一列固定,其余左右滑。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d28cd6a4..91b21988 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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` 第三轮。 diff --git a/docs/tasks/PROGRESS-chart-ui-114-20261027.md b/docs/tasks/PROGRESS-chart-ui-114-20261027.md index d3a0269c..c8e00291 100644 --- a/docs/tasks/PROGRESS-chart-ui-114-20261027.md +++ b/docs/tasks/PROGRESS-chart-ui-114-20261027.md @@ -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` | + +验证见本轮提交说明与下方数字。 diff --git a/docs/testing/chart-board-114-20261027.md b/docs/testing/chart-board-114-20261027.md index 356fbb67..1ee46f75 100644 --- a/docs/testing/chart-board-114-20261027.md +++ b/docs/testing/chart-board-114-20261027.md @@ -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. 宫位主题只有有星的宫;时运流年的日期框不再占满整行。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 349f269b..27452667 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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 同意)。 diff --git a/frontend/src/components/chart-board/chart-board-card.tsx b/frontend/src/components/chart-board/chart-board-card.tsx index 332c110e..58f748c8 100644 --- a/frontend/src/components/chart-board/chart-board-card.tsx +++ b/frontend/src/components/chart-board/chart-board-card.tsx @@ -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 = [ +/** One row: a number layer or a set of point tags, never both at once. */ +const LAYERS: ReadonlyArray = [ ["none", "无图层"], ["sav", "八宫点"], ["bhava", "宫力"], - ["transit", "叠加行运"], + ["transit", "行运"], + ["upagraha", "虚星"], + ["special", "特殊点"], + ["arudha", "映射"], ]; +const POINT_LAYERS: ReadonlySet = new Set(["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; - /** 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({ {style === "north" ? "北印度盘" : "南印度盘"} - {style === "north" ? "宫位固定" : "星座固定"} · 整宫制 - - - -
- {natal ? ( - value && onLayer(value as ChartBoardLayer)}> - - {LAYERS.map(([id, label]) => ( - {label} - ))} - - - ) : } - {natal && onPointLayer ? ( - value && onPointLayer(value as PointGroup | "none")}> - - 不标点 - 虚星 - 特殊点 - 映射 - - - ) : null} + value && onStyle(value as ChartBoardStyle)}> - + 北印度 南印度 -
- {style === "north" && caption ? ( -
- {caption.kicker} - {caption.title} - {caption.detail ? {caption.detail} : null} - {"footer" in caption && caption.footer ? {caption.footer} : null} -
+ + + + + {natal ? ( + { + if (!value) return; + if (POINT_LAYERS.has(value)) { + onLayer("none"); + onPointLayer?.(value as PointGroup); + } else { + onPointLayer?.("none"); + onLayer(value as ChartBoardLayer); + } + }}> + + {LAYERS.filter(([id]) => onPointLayer || !POINT_LAYERS.has(id)).map(([id, label]) => ( + {label} + ))} + + ) : null} {source && model ? (
> = { }; 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> = { + 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, 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(); 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(); 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 ( @@ -219,16 +277,14 @@ export function ChartBoardSvg({ ) : null} - ) : labelAt ? ( + ) : slot ? ( <> - + {isAsc ? `升 ${signName}` : signName} + {note ? {note.text} : null} - {note ? ( - cell.hub.y ? -22 : 30) : cell.hub.y + 40} textAnchor="middle">{note.text} - ) : null} {transit.length > 0 ? ( - cell.hub.y ? -10 : 42) : cell.hub.y - 34} textAnchor="middle"> + {transit.map((key) => PLANET_GLYPH_ZH[key]).join(" ")} ) : null} @@ -268,15 +324,15 @@ export function ChartBoardSvg({ {focusHub ? ( @@ -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 ( {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 ( diff --git a/frontend/src/components/chart-board/chart-board.css b/frontend/src/components/chart-board/chart-board.css index c1404951..b72d9de9 100644 --- a/frontend/src/components/chart-board/chart-board.css +++ b/frontend/src/components/chart-board/chart-board.css @@ -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; } diff --git a/frontend/src/components/chart-board/chart-deep-reading.tsx b/frontend/src/components/chart-board/chart-deep-reading.tsx index 5ca9cca6..387cf089 100644 --- a/frontend/src/components/chart-board/chart-deep-reading.tsx +++ b/frontend/src/components/chart-board/chart-deep-reading.tsx @@ -187,7 +187,7 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai }, [onNeedLayer]); return (
-
+
-
+
{now ? ( <> @@ -292,8 +292,9 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai
-
+
{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 ( ); diff --git a/frontend/src/components/chart-page/chart-dasha-tab.tsx b/frontend/src/components/chart-page/chart-dasha-tab.tsx index 4c7278e2..f8a3058a 100644 --- a/frontend/src/components/chart-page/chart-dasha-tab.tsx +++ b/frontend/src/components/chart-page/chart-dasha-tab.tsx @@ -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; layerFailures?: ReadonlyMap; @@ -282,8 +285,8 @@ export function ChartDashaTab({ return (
-
- +
+ {secondOnly ? null : } {pending || failure ? (
{switcher} diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index d6d78acc..5f79ffca 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -323,7 +323,7 @@ export function ChartPageView({ if (value) selectSystem(value as ChartSystem); }} > - + 印度盘 {journey?.tabHasDot("vedic") ? : null} diff --git a/frontend/tests/chart-board-20261027.test.tsx b/frontend/tests/chart-board-20261027.test.tsx index d1432453..95dd76a4 100644 --- a/frontend/tests/chart-board-20261027.test.tsx +++ b/frontend/tests/chart-board-20261027.test.tsx @@ -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(/([^<]+)/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+)宫"[^>]*> { const view = await okView(); const source = boardSource(view, "D1")!;