From 36fd4b2c83028ab783586992729ec78e1caa5999 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 27 Oct 2026 17:28:14 +0800 Subject: [PATCH] Chart page back to the signed-off prototype (issue #114, round 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 followed the competitor screenshots instead of prototype v2. Restored from the prototype source: page head (name, system switch, report and chat buttons), layer row left / style right, the caption strip with dasha chips, north board drawn from house centroids with sign glyph + name + house number, dignity colours and " R", twelve house theme chips with dots, wrapped antardasha chips, card hints, inspector empty state, deep-reading hints, chevron date steps and 此刻, year axis under the mahadasha scroll, three-column birth data. Point layers stay in the layer group; side triangles stack bodies so nothing leaves the frame (BUG-1465 test kept). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v --- CHANGELOG.md | 9 + docs/tasks/PROGRESS-chart-ui-114-20261027.md | 4 + docs/testing/chart-board-114-20261027.md | 11 +- frontend/DESIGN.md | 12 +- .../chart-board/chart-board-card.tsx | 94 ++++++--- .../chart-board/chart-board-svg.tsx | 187 ++++++++---------- .../components/chart-board/chart-board.css | 61 ++++-- .../chart-board/chart-deep-reading.tsx | 71 +++++-- .../chart-board/chart-inspector.tsx | 4 +- .../chart-board/chart-side-cards.tsx | 18 +- .../components/chart-page/chart-page-view.tsx | 46 +++-- frontend/tests/chart-board-20261027.test.tsx | 11 +- 12 files changed, 310 insertions(+), 218 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81c12b17..c9fcfb84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 印度占星 Skill 更新日志 +## 2026-10-27 — 星盘页按签过的原型改回来 + +- 盘面按原型画:每一宫写星座符号和名字,下一行写宫号(上升宫写「上升 1」);星按庙旺绿、落陷红着色,逆行写 R。 +- 盘上方的说明条回来了:上升、度数,右边是当前大运和分运;点了星或宫,这一条改写成选中的内容。 +- 图层在左(无图层、八宫点、宫力、叠加行运、虚星、特殊点、映射),北印度 / 南印度在右。 +- 页头:左边「名字 · 本命盘」,右边印度盘 / 西洋盘、「生成报告」「带这张盘去对话」。 +- 宫位主题十二宫都在,有星的带小圆点;分运小签换行排;各卡片右上角的提示语、检视的说明、日期按钮的箭头和「此刻」、「人生长卷」的年份刻度、排盘底册三列资料,都按原型。 +- Skill 版本不变。 + ## 2026-10-27 — 输入框不再发灰,会话列表清爽一些 - 首页和对话页的输入框:没打字时不再整块变灰、文字发淡。 diff --git a/docs/tasks/PROGRESS-chart-ui-114-20261027.md b/docs/tasks/PROGRESS-chart-ui-114-20261027.md index c8e00291..d3a3e360 100644 --- a/docs/tasks/PROGRESS-chart-ui-114-20261027.md +++ b/docs/tasks/PROGRESS-chart-ui-114-20261027.md @@ -69,3 +69,7 @@ | 时运流年底部重复一列 Vimshottari 手风琴 | 复用旧大运页 | `ChartDashaTab secondOnly` | 验证见本轮提交说明与下方数字。 + +## 第五轮:按原型改回(2026-10-27 晚) + +产品:「you did not follow prototype」。第三轮我参照竞品图改了版式,偏离了产品签过的原型 v2(说明条删了、北南切换挪进标题、宫位主题只列有星的宫、分运单行横滑、各卡提示语删了)。本轮以原型源码为准逐项对齐:页头三件、盘卡工具行与说明条、北印度画法(重心定位、符号 + 名字 + 宫号、尊贵着色、「 R」)、右栏四卡提示与样式、检视空态、深读提示、日期按钮、人生长卷年份刻度、排盘底册三列。保留第二轮产品要的标点图层(并进图层组)和第二条运法(Chara / Yogini,原型没有,但是既有能力)。为不出框偏离原型三处,写在 DESIGN.md §15。 diff --git a/docs/testing/chart-board-114-20261027.md b/docs/testing/chart-board-114-20261027.md index 91b5bb86..50d7f31e 100644 --- a/docs/testing/chart-board-114-20261027.md +++ b/docs/testing/chart-board-114-20261027.md @@ -9,7 +9,7 @@ 3. 点盘上的「土」:它所在的宫变浅色描边,实线连到它管的宫,虚线连到它看的宫,其他宫变淡;右边九曜表「土星」那行亮;检视里有位置条、月宿、尊贵九格、六力(数字 + 强/中/弱)、视相、它管的宫、主管大运、此刻行运。 4. 按键盘右方向键,切到下一颗星;按 Esc,全部收起。 5. 点「宫位主题」里的「第 7 宫」:盘上第 7 宫亮,实线连到它的宫主所在的宫;检视写宫主、宫内星、看这一宫的星、宫力、八宫点。 -6. 图层依次点「八宫点」「宫力」「行运」,每宫出现数字或行运小字,下面一行灰字说明;切到 D9,图层这一排消失。 +6. 图层依次点「八宫点」「宫力」「叠加行运」,每宫出现数字或行运小字,下面一行灰字说明;切到 D9,图层这一排消失。 7. 检视里点「在 D9 定位」,盘换成 D9,同一颗星仍选中。 8. 检视里点「问 AI:这颗星」:跳到对话页,输入框里已经是一句问题,没有发出去,点数没变。 @@ -43,12 +43,17 @@ ## 第三轮:排整齐(BUG-1465) 22. 电脑和手机各看一次北印度盘:十二个星座名各在自己那一格里,没有两个叠在一起;所有星和度数都在盘框里;手机上盘的四边都看得到。 -23. 盘卡只有两排控件(盘型、图层);北印度 / 南印度在卡片标题右边。开「虚星」再点「八宫点」,虚星胶囊消失、八宫点数字出现(一次一个)。 +23. 开「虚星」再点「八宫点」,虚星胶囊消失、八宫点数字出现(一次一个)。 24. 南印度盘开「映射」:同一格里的 AL、A11 两枚胶囊不重叠。 -25. 宫位主题只有有星的宫;时运流年的日期框不再占满整行。 +25. 时运流年的日期框不占满整行。 ## 第四轮:输入框与会话列表(BUG-1466) 26. 首页输入框没打字时:外框里没有灰底块,占位字和模型名颜色正常。 27. 电脑首页侧栏:会话行没有左边的六点把手;「…」只在鼠标移上去或当前那条出现。按住一条往上拖到别处松手,顺序变了、没有打开那条对话;只点一下则正常打开。刷新后顺序还在。 28. 手机侧栏:轻扫能正常滚动列表;长按一条再拖可以排序。 + +## 第五轮:按原型(https://claude.ai/artifact/55AgsjsV8cFRobtM4d2cKz) + +29. 拿原型和真页面并排:页头(名字 · 本命盘、印度盘/西洋盘、生成报告、带这张盘去对话)、盘卡(图层在左、北南在右、说明条带大运分运小签)、盘面(星座符号 + 名字 + 宫号、庙旺绿落陷红、R 逆行)、右栏四张卡的右上角提示、宫位主题十二枚带圆点、分运小签换行,都和原型一致。 +30. 点「带这张盘去对话」:到对话页,右栏是本命盘。点「生成报告」:到报告中心。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index a368c541..d8e2ef68 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1040,21 +1040,21 @@ 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="line"`,10-27 第三轮由分段改回;底线与下面的两栏同宽 1240px),西洋盘页原样保留。印度盘之下一行四枚圆角小签:上升 / 月亮 / 太阳 / 当前大运,点了在盘上定位,再点取消。 +- **页头**(10-27 第五轮按原型):顶栏出生资料一行不变;正文第一行左边「名字 · 本命盘」(宋体 20px),右边「印度盘 / 西洋盘」分段 `Tabs`、「生成报告」(描边,去 `/reports`)、「带这张盘去对话」(实心,去 `/`,对话右栏默认开着本命盘)。西洋盘页原样保留。印度盘之下一行四枚圆角小签:上升 / 月亮 / 太阳 / 当前大运,点了在盘上定位,再点取消。 - **两栏**(≥1024px,左约 53%、右约 47%;更窄时单列):左列「盘」卡 + 「盘面检视」卡 + 当前盘型自己的表(D1 没有这张,九曜卡就是本命表);右列「九曜」「宫位主题」「Vimshottari 大运」「命盘速览」四张卡。卡片白底(`--color-canvas`)、16px 圆角、一条发丝阴影,标题宋体 16px,不加红色竖条。 -- **盘卡**(10-27 第三轮收拾,产品说「乱」):标题「北印度盘 / 南印度盘」,标题右侧是北印度 / 南印度小号分段 `Tabs`(原来的「宫位固定 · 整宫制」小字删掉)。卡内只有两排控件:23 种盘型条,下面一排图层「无图层 / 八宫点 / 宫力 / 行运 / 虚星 / 特殊点 / 映射」(只在本命 D1 出现,一次只开一个,手机上这一排横滑不折行)。北印度盘上方原来的浅底说明条删掉(选中内容看检视卡);南印度仍把上升与当前运期写在盘中央。卡内网格列 `minmax(0, 1fr)`,盘与控件不会把卡撑宽(第二轮之前手机上盘右侧被裁)。南北的选择全站只记一份(`localStorage` `chart_board_style`),默认北。 +- **盘卡**(10-27 第五轮按原型改回,推翻第三轮):标题「北印度盘 / 南印度盘」,右侧小字「宫位固定 / 星座固定 · 整宫制」。23 种盘型条。下面一行左边图层分段 `Tabs`「无图层 / 八宫点 / 宫力 / 叠加行运 / 虚星 / 特殊点 / 映射」(只在本命 D1,一次一个;虚星等三项是第二轮产品要的标点图层,并在同一组里),右边北印度 / 南印度。北印度盘上方浅底说明条:未选中时「本命盘 D1 / 上升 狮子 / 度数」,右端两枚小签「某星大运」「某星分运」;选中星时写「D1 · 选中 / 星名 / 星座度数 · 第 N 宫 · 实线=它管的宫,虚线=它看的宫」,选中宫写「…· 实线连到宫主所在」。南印度把同样内容写在盘中央。卡片内容网格列 `minmax(0, 1fr)`,手机上不撑宽。 - **盘面**(`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 第二轮,产品选「做成图层」;第三轮并进图层那一排,不再与数值图层同时开):虚星 / 特殊点 / 映射。每个点画成一枚小胶囊(`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`(那是报告盘写数字用的内角位置,换成汉字就会在中心和对角线上撞成一团)。 +- **北印度格内位置**(10-27 第五轮按原型,BUG-1465 的约束仍在):每一宫以多边形重心为中心。宫内先写「星座符号 星座名」(10px,三级色),下一行宫号(11px;上升宫写「上升 1」,主色加粗);菱形写在重心上方 30,三角写在上方 14(无星时 2)。星体在下:菱形字号 20、三角 16(拥挤时 16 / 14),度数写在字右上,逆行加「 R」(罗计不写)。三处为不出框偏离原型:左右两侧三角星体竖排一列;上方两个三角星体下移到重心下 14,以免压住宫号;下方两个三角星座名写到星体下面;三角里同一行两颗星的间距 2.2 倍字号。星体按尊贵着色:入旺 / 本座 / 入庙绿(`--color-success`),落陷红(`--color-danger`),其余墨色;只在本命 D1。标点胶囊在星座名上方一排(上方两个三角在星体下方)。 - **盘面检视**:空时一句「点盘上的星或宫看详情」+「方向键切换 · 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 宫 · 主题」,有星的前面一个主色小圆点;卡头右侧「点一下,盘上对应的宫亮起」。**大运卡**:一排大运小卡,当前那张主色描边,下面换行排它的分运小签「星字 年.月」,当前分运主色描边浅底;卡头右侧「点一段,下面看分运」。**命盘速览**:两列六格左对齐、无分隔线,灵魂星写「本盘度数最高的星 · 星座 度数」;卡头右侧「点一项,在盘上定位」。九曜卡头右侧「恒星黄道 · 本命」,星名后逆行写「 R」。检视空态三行:「点盘上的星或宫看详情」「点了之后,盘上会连出它管的宫和它看的宫,右边表格同一行也会亮。」「方向键在九曜或十二宫间切换 · Esc 收起」,卡头右侧「点星体或宫位」。 - **深读**(`#chart-deep`):四个分段页签「时运流年(带当前大运 · 分运字)/ 能量图谱 / 命性格局(带成立组数)/ 排盘底册」,默认时运流年。 - - 时运流年:观测日期(11.5rem 宽的日期框与上月 / 下月 / 上年 / 下年 / 回到今天同一行,1000–2999 年任意日;全站 `input:not(...)` 规则会把输入框撑满整行,这里用更具体的选择器压住);当前运期三层进度条(大运、分运、子运;子运用与分运同一个比例公式,`lib/dasha-span.ts`);九曜行运表(星座、上升起、月亮起、该座八宫点 + 强中弱;表头右侧写土星七年半第几段或未激活);「叠到盘面」;Vimshottari 人生长卷三行等比条(大运 → 点开的分运 → 子运,当前段主色底、观测日一根竖线,620px 以下横滚);最下面「第二条运法」只放 Chara / Yogini 一栏(`ChartDashaTab secondOnly`,最宽 640px);Vimshottari 上面已经画过,不再重复一列手风琴。 + - 深读卡头右侧「时运 · 能量 · 格局 · 底册」。时运流年:观测日期(右侧提示「换日期,下面的运期、行运和盘上的行运图层一起变」;11.5rem 宽的日期框与「‹ 上月」「下月 ›」「‹ 上年」「下年 ›」(Lucide 箭头)和文字链「此刻」同一行,1000–2999 年任意日;全站 `input:not(...)` 规则会把输入框撑满整行,这里用更具体的选择器压住);当前运期三层进度条(大运、分运、子运;子运用与分运同一个比例公式,`lib/dasha-span.ts`);九曜行运表(星座、上升起、月亮起、该座八宫点 + 强中弱;表头右侧写土星七年半第几段或未激活);「叠到盘面」;「人生长卷」三行等比条(大运一行下面有年份刻度,每 20 年一个)(大运 → 点开的分运 → 子运,当前段主色底、观测日一根竖线,620px 以下横滚);最下面「第二条运法」只放 Chara / Yogini 一栏(`ChartDashaTab secondOnly`,最宽 640px);Vimshottari 上面已经画过,不再重复一列手风琴。 - 能量图谱:行星六力(总量条 + 竖线门槛 + 六段分量彩条,右侧百分比、强中弱、鲁帕);宫位力量柱状图(均值虚线,点柱选宫);八宫点矩阵(按宫位 / 按星座,原始 / 三角削减 / 两步削减,格子按点数深浅,末行合计)。各自「叠到盘面」。 - 命性格局:只列与对话卡同一张闭合清单(五大人物、象狮、落陷取消、日水同宫)里成立的,引擎报了削弱的写「部分满足」;没成立的折叠在「未满足 N 条」里。点一条,切回 D1、盘上只留这几颗星的颜色。底部一句「传统格局,本站未验证,只作参考」。 - - 排盘底册:参数键值表 + 边界句(原样);出生历法(提毗、月宿、星期);「数据表」六个分段页签(2026-10-27 第二轮,产品贴竞品五张表):行星(名册含 D9 列 + 宫主流向)/ 虚星(11 个,烟毁晕虹彗 + 六个时段点)/ 特殊点(八个特殊上升 + 瑜伽点、阻碍点)/ 映射(AL、A2~A11、UL)/ 星力明细(总表、位置力、时间力、其余四力,维鲁帕)/ 敌友(天生 / 临时 / 综合,综合五档)。首列固定,其余横滑。点虚星、特殊点、映射的一行 = 在盘上选中这个点(切回 D1),再点取消;点行星或星力行 = 选星。八宫点不另开表,能量图谱的矩阵在原始口径下多一行「升」(上升自己的 BAV,不计入合计);大运、瑜伽也不在数据表里重复(产品 10-27 同意)。 + - 排盘底册:「出生资料与计算口径」三列键值(出生当地时间、出生时区、地点、岁差、宫制、交点口径)+ 边界句;「数据表」卡头右侧「数据来自本站引擎」,行星页分「行星名册 D1」「宫主流向 第 N 宫之主落在哪一宫」两段;出生历法(提毗、月宿、星期);「数据表」六个分段页签(2026-10-27 第二轮,产品贴竞品五张表):行星(名册含 D9 列 + 宫主流向)/ 虚星(11 个,烟毁晕虹彗 + 六个时段点)/ 特殊点(八个特殊上升 + 瑜伽点、阻碍点)/ 映射(AL、A2~A11、UL)/ 星力明细(总表、位置力、时间力、其余四力,维鲁帕)/ 敌友(天生 / 临时 / 综合,综合五档)。首列固定,其余横滑。点虚星、特殊点、映射的一行 = 在盘上选中这个点(切回 D1),再点取消;点行星或星力行 = 选星。八宫点不另开表,能量图谱的矩阵在原始口径下多一行「升」(上升自己的 BAV,不计入合计);大运、瑜伽也不在数据表里重复(产品 10-27 同意)。 - **等待与失败**:揭幕后不出现骨架或「正在…」:盘型懒层沿用盘位骨架(BUG-1016 例外);八宫点、行运、Chara 未到时那一块留空,失败时写原因句。 - **对话右栏**(`ChatNatalChart`):标题「名字 · 本命盘」+「打开星盘页」,下面小签、盘卡、检视、九曜(不带六力列)。桌面默认打开(`chat_chart_panel_open` 没存过即为开),宽 320–760、默认 440,拖到最窄以下收起,宽度记在本机。手机顶栏多一个「看本命盘」图标按钮,从底部拉起同一套盘面(`Drawer`,86dvh),「问 AI」会先收起抽屉再聚焦输入框。样式在 `chart-board.css`,只随星盘页和右栏的懒加载块到达,不进首页首屏。 diff --git a/frontend/src/components/chart-board/chart-board-card.tsx b/frontend/src/components/chart-board/chart-board-card.tsx index 58f748c8..cab34746 100644 --- a/frontend/src/components/chart-board/chart-board-card.tsx +++ b/frontend/src/components/chart-board/chart-board-card.tsx @@ -6,7 +6,8 @@ import { ChartTypePicker } from "@/components/chart-page/chart-type-picker"; import { ChartPageSkeleton } from "@/components/chart-page/chart-page-skeleton"; import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; import type { BoardModel, BoardSelection } from "@/lib/chart-board-model"; -import { bodyHouse } from "@/lib/chart-board-model"; +import { bodyHouse, dignityStep } from "@/lib/chart-board-model"; +import { Badge } from "@/components/ui/badge"; import { BAND_ZH, bhavaBand, @@ -26,7 +27,7 @@ import { } from "@/lib/chart-board-view"; import type { ChartViewFailureStatus, ChartViewOk, ChartViewTransitOk } from "@/lib/chart-view-contract"; import { CHART_VIEW_COPY, type ChartTypeId } from "@/lib/chart-view-labels"; -import { PLANET_NAME_ZH, type PlanetGlyphKey } from "@/lib/planet-glyphs"; +import { PLANET_NAME_ZH, planetGlyphKey, type PlanetGlyphKey } from "@/lib/planet-glyphs"; import { formatDegreeMinutes } from "@/components/chart-page/chart-format"; import { ChartBoardSvg, type ChartBoardHouseNote, type ChartBoardPointMark, type ChartBoardStyle } from "./chart-board-svg"; @@ -36,7 +37,7 @@ const LAYERS: ReadonlyArray = [ ["none", "无图层"], ["sav", "八宫点"], ["bhava", "宫力"], - ["transit", "行运"], + ["transit", "叠加行运"], ["upagraha", "虚星"], ["special", "特殊点"], ["arudha", "映射"], @@ -69,6 +70,7 @@ export function boardCaption(view: ChartViewOk, source: BoardSource, model: Boar kicker: `${source.natal ? "D1" : source.title} · 选中`, title: PLANET_NAME_ZH[selection.key], detail: body ? `${signLabelAt(body.sign)} ${formatDegreeMinutes(body.degree)} · 第${body.house}宫` : "", + note: "实线=它管的宫,虚线=它看的宫", }; } if (selection?.kind === "point") { @@ -85,6 +87,7 @@ export function boardCaption(view: ChartViewOk, source: BoardSource, model: Boar kicker: `${source.natal ? "D1" : source.title} · 选中`, title: `第${selection.house}宫`, detail: `${signLabelAt(sign)} · ${HOUSE_THEME_ZH[selection.house - 1]}`, + note: "实线连到宫主所在", }; } const asc = model.houses[model.ascendantHouse - 1]; @@ -94,9 +97,23 @@ export function boardCaption(view: ChartViewOk, source: BoardSource, model: Boar title: `上升 ${signLabelAt(asc?.sign ?? -1)}`, detail: source.natal && view.vedic.ascendant ? formatDegreeMinutes(view.vedic.ascendant.degreeInSign) : "", footer: now ? `${now.maha.label}大运${now.antar ? ` · ${now.antar.label}分运` : ""}` : undefined, + chips: now ? [`${now.maha.label}大运`, ...(now.antar ? [`${now.antar.label}分运`] : [])] : [], }; } +/** Green for exalted, own sign or moolatrikona; red for debilitated; the rest stay ink. */ +function dignityTones(view: ChartViewOk): ReadonlyMap { + const tones = new Map(); + for (const row of view.vedic.planets) { + const key = planetGlyphKey(row.name); + const step = dignityStep(row.status); + if (!key || !step) continue; + if (step.step === 0) tones.set(key, "bad"); + else if (step.step >= 6) tones.set(key, "good"); + } + return tones; +} + export function ChartBoardCard({ view, type, @@ -163,34 +180,48 @@ export function ChartBoardCard({ {style === "north" ? "北印度盘" : "南印度盘"} - + {style === "north" ? "宫位固定" : "星座固定"} · 整宫制 + + + +
+ {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} + ))} + + + ) : } value && onStyle(value as ChartBoardStyle)}> - + 北印度 南印度 - - - - - {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} - ))} - - +
+ {style === "north" && caption ? ( +
+ {caption.kicker} + {caption.title} + {caption.detail ? {caption.detail} : null} + {"note" in caption && caption.note ? · {caption.note} : null} + {"chips" in caption && caption.chips?.length ? ( + + {caption.chips.map((chip) => {chip})} + + ) : null} +
) : null} {source && model ? (
) : !pending && failure ? ( @@ -218,11 +250,11 @@ export function ChartBoardCard({ )}
- 入旺 / 本座 - 落陷 - 逆 = 逆行 - 它管的宫 - 它看的宫 + 绿入旺 / 本座 + 红落陷 + R 逆行 + 宫主落点 + 视相
{natal && activeLayer !== "none" && layerFailure && !layerPending ? (

{chartLayerFailureCopy(layerFailure)}

diff --git a/frontend/src/components/chart-board/chart-board-svg.tsx b/frontend/src/components/chart-board/chart-board-svg.tsx index 420aedb2..bb12328d 100644 --- a/frontend/src/components/chart-board/chart-board-svg.tsx +++ b/frontend/src/components/chart-board/chart-board-svg.tsx @@ -22,8 +22,8 @@ import { type BoardSelection, } from "@/lib/chart-board-model"; import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels"; -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"; +import { CHART_SIZE, HOUSE_POLYGONS, polygonCentroid, polygonPoints, type Point } from "@/lib/north-indian-chart-geometry"; +import { PLANET_GLYPH_ZH, PLANET_NAME_ZH, type PlanetGlyphKey } from "@/lib/planet-glyphs"; export type { ChartBoardStyle } from "@/lib/chart-board-style"; @@ -44,84 +44,37 @@ const SOUTH_CELL: Readonly> = { }; 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; lines meet the house here too. */ + /** Centre of the house: label, bodies and lines all hang off it (as in the prototype). */ anchor: Point; - perRow: number; - /** North only. */ - slot: NorthSlot | null; + triangle: boolean; + /** North only: the narrow left and right triangles stack their bodies in a column. */ + side: "left" | "right" | null; }; +/** Sign glyphs with the text-presentation selector, so phones do not swap in emoji. */ +const SIGN_GLYPH = ["\u2648", "\u2649", "\u264A", "\u264B", "\u264C", "\u264D", "\u264E", "\u264F", "\u2650", "\u2651", "\u2652", "\u2653"] + .map((glyph) => `${glyph}\uFE0E`); + +const SIDE_HOUSES: Readonly> = { 3: "left", 5: "left", 9: "right", 11: "right" }; +/** The bottom triangles are widest at the frame, so there the name goes under the bodies. */ +const BOTTOM_HOUSES: ReadonlySet = new Set([6, 8]); +const TOP_HOUSES: ReadonlySet = new Set([2, 12]); + function cellsFor(model: BoardModel, style: ChartBoardStyle): Cell[] { return model.houses.map((house) => { if (style === "north") { - const slot = NORTH_SLOTS[house.house]!; + const points = HOUSE_POLYGONS[house.house]!; return { house: house.house, sign: house.sign, - shape: { kind: "polygon" as const, points: HOUSE_POLYGONS[house.house]! }, - anchor: slot.bodies, - perRow: slot.perRow, - slot, + shape: { kind: "polygon" as const, points }, + anchor: polygonCentroid(points), + triangle: points.length === 3, + side: SIDE_HOUSES[house.house] ?? null, }; } const [col, row] = SOUTH_CELL[house.sign] ?? [0, 0]; @@ -129,28 +82,39 @@ 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 + 2 }, - perRow: 3, - slot: null, + anchor: { x: col * CELL + CELL / 2, y: row * CELL + CELL / 2 + 6 }, + triangle: false, + side: null, }; }); } -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); - // 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; +/** + * Body positions, from the prototype the product owner signed off: 20-unit + * glyphs in the North diamonds and South cells, 16 in the triangles, smaller + * when crowded, the degree written above right. The side triangles are too + * narrow for two glyphs with their degrees, so there the bodies stack. + */ +function bodyLayout(count: number, cell: Cell, style: ChartBoardStyle): { size: number; points: Point[] } { + const north = style === "north"; + const size = north && cell.triangle ? (count > 2 ? 14 : 16) : count > 3 ? 16 : 20; + const cols = cell.side ? 1 : count <= 2 ? Math.max(count, 1) : count <= 4 ? 2 : 3; + const rows = Math.ceil(count / cols); + // The prototype's 1.9 lets a retrograde degree (「1° R」) touch the next glyph in a triangle. + const stepX = size * (north && cell.triangle ? 2.2 : 1.9); + const stepY = size * 1.35; + const base = !north + ? { x: cell.anchor.x, y: cell.anchor.y - 2 } + : cell.side + ? { x: cell.anchor.x + (cell.side === "left" ? 0 : -8), y: cell.anchor.y + 12 + ((rows - 1) * stepY) / 2 } + : { x: cell.anchor.x, y: cell.anchor.y + (!cell.triangle ? 2 : TOP_HOUSES.has(cell.house) ? 14 : BOTTOM_HOUSES.has(cell.house) ? -8 : 8) }; 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; + const row = Math.floor(index / cols); + const inRow = Math.min(cols, count - row * cols); + const col = index % cols; return { - x: anchor.x + (col - (inRow - 1) / 2) * stepX - size * 0.45, - y: anchor.y + (row - (rows - 1) / 2) * stepY + size * 0.35, + x: base.x + (col - (inRow - 1) / 2) * stepX - 3, + y: base.y + (row - (rows - 1) / 2) * stepY + size * 0.35, }; }); return { size, points }; @@ -183,7 +147,10 @@ export function ChartBoardSvg({ center, pointMarks = [], onPointSelect, + dignity, }: { + /** Natal boards only: green for exalted / own sign, red for debilitated (prototype legend). */ + dignity?: ReadonlyMap; model: BoardModel; style: ChartBoardStyle; selection: BoardSelection; @@ -257,15 +224,18 @@ export function ChartBoardSvg({ const note = houseNotes?.[cell.house - 1] ?? null; const signName = signZh(SIGN_ORDER[cell.sign] ?? ""); const isAsc = cell.house === model.ascendantHouse; - const slot = cell.slot; - const layout = bodyLayout(house.bodies.length, cell.anchor, cell.perRow, style === "north"); + const layout = bodyLayout(house.bodies.length, cell, style); + const { x: cx, y: cy } = cell.anchor; + const labelY = !cell.triangle ? cy - 30 + : BOTTOM_HOUSES.has(cell.house) && house.bodies.length > 0 ? cy + 16 + : cy - (house.bodies.length > 0 ? 14 : 2); const transit = overlayBySign.get(cell.sign) ?? []; return ( {cell.shape.kind === "rect" ? ( <> - {signName} - + {`${SIGN_GLYPH[cell.sign] ?? ""} ${signName}`} + {cell.house} {note ? ( @@ -277,28 +247,33 @@ export function ChartBoardSvg({ ) : null} - ) : slot ? ( + ) : ( <> - - {isAsc ? `升 ${signName}` : signName} - {note ? {note.text} : null} + {`${SIGN_GLYPH[cell.sign] ?? ""} ${signName}`} + + {isAsc ? `上升 ${cell.house}` : cell.house} + {note ? ( + {note.text} + ) : null} {transit.length > 0 ? ( - + {transit.map((key) => PLANET_GLYPH_ZH[key]).join(" ")} ) : null} - ) : null} + )} {house.bodies.map((body, index) => { const point = layout.points[index]!; const selected = selectedKey === body.key; const dim = dimmedBodies?.has(body.key) ?? false; + const tone = dignity?.get(body.key); + const node = body.key === "rahu" || body.key === "ketu"; const select = () => onSelect(selected ? null : { kind: "body", key: body.key }); return ( {PLANET_GLYPH_ZH[body.key]} - - {`${Math.floor(body.degree)}°${body.retrograde ? "逆" : ""}`} + + {`${Math.floor(body.degree)}°${body.retrograde && !node ? " R" : ""}`} ); @@ -340,16 +315,14 @@ export function ChartBoardSvg({ {cells.map((cell) => { const marks = marksBySign.get(cell.sign) ?? []; 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). - // 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); + // South: rows along the cell's lower edge. North: above the sign name, as the + // prototype placed a picked point; the top triangles have no room there, so below the bodies. + const perRow = style === "south" || !cell.triangle ? 3 : 2; + const below = cell.house === 2 || cell.house === 12; + const base = cell.shape.kind === "rect" + ? { x: cell.shape.x + CELL / 2, y: cell.shape.y + CELL - 24 } + : { x: cell.anchor.x, y: below ? cell.anchor.y + 28 : cell.anchor.y - (cell.triangle ? 30 : 46) }; + const rowStep = cell.shape.kind === "rect" || below ? 15 : -15; return ( {marks.map((mark, index) => { @@ -360,7 +333,7 @@ export function ChartBoardSvg({ const col = index % perRow; 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 y = base.y + row * rowStep; 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 b72d9de9..e8a5da9a 100644 --- a/frontend/src/components/chart-board/chart-board.css +++ b/frontend/src/components/chart-board/chart-board.css @@ -38,12 +38,19 @@ .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: 4px 10px; + padding: 8px 12px; border-radius: var(--radius-sm); 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; line-height: 1.2; } +.chart-board-caption-detail { font-size: 12px; color: var(--color-ink-secondary); } +.chart-board-caption-chips { display: inline-flex; gap: 6px; margin-left: auto; align-self: center; } + /* 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%; } @@ -66,26 +73,20 @@ .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: 12px; fill: var(--color-ink-tertiary); } -.chart-board-sign.is-asc { fill: var(--color-action); font-weight: 600; font-size: 13px; } +.chart-board-sign { font-size: 10px; fill: var(--color-ink-tertiary); } .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); } .chart-board-note.is-high { fill: var(--color-action); font-weight: 600; } .chart-board-note.is-low { fill: var(--color-planet-saturn); } -.chart-board-transit { font-size: 11px; fill: var(--color-planet-saturn); font-family: var(--font-display); } +.chart-board-transit { font-size: 11px; font-weight: 500; fill: var(--color-planet-saturn); } .chart-board-body { cursor: pointer; outline: none; } .chart-board-body text { pointer-events: auto; } .chart-board-glyph { font-family: var(--font-display); font-weight: 600; fill: var(--color-ink); } -.chart-board-body.is-sun .chart-board-glyph { fill: var(--color-planet-sun); } -.chart-board-body.is-moon .chart-board-glyph { fill: var(--color-planet-moon); } -.chart-board-body.is-mars .chart-board-glyph { fill: var(--color-planet-mars); } -.chart-board-body.is-mercury .chart-board-glyph { fill: var(--color-planet-mercury); } -.chart-board-body.is-jupiter .chart-board-glyph { fill: var(--color-planet-jupiter); } -.chart-board-body.is-venus .chart-board-glyph { fill: var(--color-planet-venus); } -.chart-board-body.is-saturn .chart-board-glyph { fill: var(--color-planet-saturn); } -.chart-board-body.is-node .chart-board-glyph { fill: var(--color-planet-node); } +/* Prototype legend: green = exalted / own sign, red = debilitated; natal boards only. */ +.chart-board-body.is-good .chart-board-glyph { fill: var(--color-success); } +.chart-board-body.is-bad .chart-board-glyph { fill: var(--color-danger); } .chart-board-degree { font-size: 9px; fill: var(--color-ink-tertiary); } .chart-board-body.is-selected .chart-board-glyph { text-decoration: underline; } .chart-board-body.is-dim { opacity: .25; } @@ -103,7 +104,8 @@ .chart-board-center-title { font-family: var(--font-display); font-weight: 700; font-size: 26px; fill: var(--color-ink); } .chart-board-center-detail { font-size: 11px; fill: var(--color-ink-secondary); } -.chart-board-legend { display: flex; flex-wrap: wrap; gap: 4px var(--space-3); font-size: 12px; color: var(--color-ink-secondary); } +.chart-board-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 12px; color: var(--color-ink-secondary); } +.chart-board-legend .chart-band { margin: 0 4px 0 0; } .chart-board-legend span { display: inline-flex; align-items: center; gap: 4px; } .chart-board-key { display: inline-block; width: 10px; height: 10px; border-radius: 3px; } .chart-board-key.is-good { background: var(--color-success-muted); border: 1px solid var(--color-success); } @@ -121,6 +123,7 @@ /* Inspector. */ .chart-inspector-nav { display: flex; gap: 0; align-self: center; } .chart-inspector-empty { font-family: var(--font-display); font-size: 16px; font-weight: 600; margin: 0 0 var(--space-1); } +.chart-inspector-lead { font-size: 13px; color: var(--color-ink-secondary); margin: 0 0 var(--space-2); } .chart-inspector-hint { font-size: 12px; color: var(--color-ink-tertiary); margin: 0; } .chart-inspector-body { display: grid; gap: 0; } .chart-inspector-head { display: flex; gap: var(--space-3); align-items: flex-start; padding-bottom: var(--space-3); } @@ -189,6 +192,7 @@ .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); } @@ -197,16 +201,14 @@ .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); flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; } -.chart-dasha-antars .chart-chip { flex: none; } +.chart-dasha-antars { margin-top: var(--space-3); } +.chart-chip.is-on { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-action); } -.chart-glance { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--space-4); } +.chart-glance { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4) 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; + width: 100%; padding: 0; border-radius: 0; 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; } @@ -361,3 +363,22 @@ /* 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; } +.chart-board-toolbar > * { min-width: 0; } + +/* Page head (prototype): name on the left; system switch, report and chat on the right. */ +.chart-page-head { width: min(1240px, 100%); margin: var(--space-4) auto 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); } +.chart-page-head-title h2 { margin: 0; font-family: var(--font-display); font-size: 20px; font-weight: 600; } +.chart-page-head-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); } +.chart-page-head .chart-page-tabs { width: auto; margin: 0; padding: 0; border: 0; overflow: visible; } +.chart-page-head .chart-page-tab { min-height: 0; min-width: 0; border-bottom: 0; padding: 0 10px; } +.chart-page-head-button { border-radius: 999px; } +.chart-book-grid.is-three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +@media (max-width: 640px) { .chart-book-grid.is-three { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.chart-date-step { gap: 2px; border-radius: 999px; } +.chart-date-step svg { width: 14px; height: 14px; } +.chart-period-top strong small { font-family: var(--font-body); font-size: 12px; font-weight: 400; color: var(--color-ink-secondary); margin-left: 4px; } +.chart-period-glyph { font-family: var(--font-display); font-size: 18px; } +.chart-scroll-axis { position: relative; height: 14px; margin-top: 4px; font-size: 11px; color: var(--color-ink-tertiary); } +.chart-scroll-axis span { position: absolute; transform: translateX(-50%); white-space: nowrap; } +.chart-scroll-axis span:first-child { transform: none; } +.chart-scroll-axis span:last-child { transform: translateX(-100%); } diff --git a/frontend/src/components/chart-board/chart-deep-reading.tsx b/frontend/src/components/chart-board/chart-deep-reading.tsx index 387cf089..8b6db54a 100644 --- a/frontend/src/components/chart-board/chart-deep-reading.tsx +++ b/frontend/src/components/chart-board/chart-deep-reading.tsx @@ -1,19 +1,18 @@ "use client"; import { useEffect, useState, type ReactNode } from "react"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; import { OnboardingDot } from "@/components/onboarding-dot"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Table } from "@/components/ui/table"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ChartDashaTab } from "@/components/chart-page/chart-dasha-tab"; import { formatDegreeMinutes } from "@/components/chart-page/chart-format"; -import { ChartParamTable } from "@/components/chart-page/chart-param-table"; import type { ChartBoardState } from "@/hooks/use-chart-board"; import { bodyHouse, dignityStep, houseLord } from "@/lib/chart-board-model"; import { @@ -86,8 +85,8 @@ function PeriodRow({ level, span, day }: { level: string; span: DashaSpan | null
- {key ? {PLANET_GLYPH_ZH[key]} : null} - {span.label} + {key ? {PLANET_GLYPH_ZH[key]} : null} + {span.label} {span.start} – {span.end}
@@ -98,7 +97,9 @@ function PeriodRow({ level, span, day }: { level: string; span: DashaSpan | null ); } -function ScrollRow({ label, range, spans, day, open, onOpen }: { +function ScrollRow({ label, range, spans, day, open, onOpen, axis = false }: { + /** Year marks under the row (the mahadasha row in the prototype). */ + axis?: boolean; label: string; range: string; spans: readonly DashaSpan[]; @@ -140,6 +141,21 @@ function ScrollRow({ label, range, spans, day, open, onOpen }: { })} {t >= a && t < b ? : null}
+ {axis ? ( + + ) : null} ); } @@ -187,7 +203,7 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai }, [onNeedLayer]); return (
-
+
- - - - - + + + + +
-
+
{now ? ( <> @@ -265,11 +281,12 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai
-
+
({ lord: item.lord, label: item.label, start: item.start, end: item.end }))} day={observe} @@ -734,19 +751,26 @@ function BookTab({ view, board, onNeedLayer }: { return (
- +
+
出生当地时间
{view.profile.date} {view.profile.time}
+
出生时区
{view.profile.timezoneLabel}
+
地点
{view.profile.placeLabel}
+
岁差
{view.profile.ayanamsaDisplay}
+
宫制
整宫制
+
交点口径
{view.profile.nodeModeLabel}
+

{view.vedic.boundary}

{calendar ? (
-
+
提毗
{calendar.tithi ?? "—"}
月宿
{moon ? `${moon.nakshatra} · 第 ${moon.pada} 足` : "—"}
星期
{calendar.weekday}
) : null} -
+
value && setTable(value as BookTable)}> {BOOK_TABLES.map(([id, label]) => {label})} @@ -754,6 +778,7 @@ function BookTab({ view, board, onNeedLayer }: { {table === "planets" ? (
+
@@ -772,12 +797,14 @@ function BookTab({ view, board, onNeedLayer }: { return ( key && board.select({ kind: "body", key })}> - + ); @@ -785,11 +812,13 @@ function BookTab({ view, board, onNeedLayer }: {
- {key ? {PLANET_GLYPH_ZH[key]} : null} {row.label} {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} · {row.house}宫 {row.nakshatra}{dignity?.label ?? "—"}{row.retrograde ? " · 逆" : ""} + = 6 ? "chart-graha-good" : undefined}>{dignity?.label ?? "—"} + {row.retrograde && key !== "rahu" && key !== "ketu" ? " 逆" : ""} + {row.d9SignLabel ?? "—"}
+
+
- + @@ -809,6 +838,7 @@ function BookTab({ view, board, onNeedLayer }: {
宫主流向宫位 宫主 落宫
+
) : null} {table === "upagraha" || table === "special" || table === "arudha" ? ( @@ -861,6 +891,7 @@ export function ChartDeepReading({ 深读 + 时运 · 能量 · 格局 · 底册 value && select(value as ChartDeepTab)}> diff --git a/frontend/src/components/chart-board/chart-inspector.tsx b/frontend/src/components/chart-board/chart-inspector.tsx index 7a7ed30a..22062ebe 100644 --- a/frontend/src/components/chart-board/chart-inspector.tsx +++ b/frontend/src/components/chart-board/chart-inspector.tsx @@ -252,10 +252,12 @@ export function ChartInspector({ 盘面检视 + 点星体或宫位

点盘上的星或宫看详情

-

方向键切换 · Esc 收起

+

点了之后,盘上会连出它管的宫和它看的宫,右边表格同一行也会亮。

+

方向键在九曜或十二宫间切换 · Esc 收起

); diff --git a/frontend/src/components/chart-board/chart-side-cards.tsx b/frontend/src/components/chart-board/chart-side-cards.tsx index eb34355b..b5288230 100644 --- a/frontend/src/components/chart-board/chart-side-cards.tsx +++ b/frontend/src/components/chart-board/chart-side-cards.tsx @@ -17,7 +17,7 @@ import { } from "@/lib/chart-board-view"; import type { ChartViewOk } from "@/lib/chart-view-contract"; import { STRENGTH_LEVEL_ZH } from "@/lib/chart-view-insight"; -import { PLANET_COLOR_CLASS, PLANET_GLYPH_ZH, planetGlyphKey } from "@/lib/planet-glyphs"; +import { PLANET_GLYPH_ZH, planetGlyphKey } from "@/lib/planet-glyphs"; function selectedBody(selection: BoardSelection) { return selection?.kind === "body" ? selection.key : null; @@ -75,9 +75,8 @@ export function ChartGrahaCard({ view, selection, onSelect, compact = false }: { onSelect(on ? null : { kind: "body", key }); }} > - {PLANET_GLYPH_ZH[key]} {row.label} - {row.retrograde ? 逆 : null} + {row.retrograde && key !== "rahu" && key !== "ketu" ? " R" : null} {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} @@ -111,12 +110,12 @@ export function ChartHouseThemesCard({ model, selection, onSelect }: { 宫位主题 + 点一下,盘上对应的宫亮起 {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 occupied = (model.houses[index]?.bodies.length ?? 0) > 0; const on = selection?.kind === "house" && selection.house === house; return ( ); @@ -148,6 +148,7 @@ export function ChartDashaStripCard({ view }: { view: ChartViewOk }) { Vimshottari 大运 + 点一段,下面看分运
@@ -172,8 +173,8 @@ export function ChartDashaStripCard({ view }: { view: ChartViewOk }) { {shown ? (
{shown.antardashas.map((antar) => ( - - {antar.label} {antar.start.slice(0, 7)} + + {(() => { const key = planetGlyphKey(antar.lord); return key ? PLANET_GLYPH_ZH[key] : antar.label; })()} {antar.start.slice(0, 7).replace("-", ".")} ))}
@@ -208,11 +209,12 @@ export function ChartGlanceCard({ view, model, onSelect, onOpenTime }: { 命盘速览 + 点一项,在盘上定位 {glance.ascendant ? item("asc", "上升", glance.ascendant.sign, formatDegreeMinutes(glance.ascendant.degree), () => onSelect({ kind: "house", house: model.ascendantHouse })) : null} {glance.moon ? item("moon", "月亮 · 月宿", glance.moon.sign, glance.moon.nakshatra, () => onSelect({ kind: "body", key: "moon" })) : null} - {glance.atmakaraka ? item("ak", "灵魂星", glance.atmakaraka.label, `${glance.atmakaraka.sign} ${formatDegreeMinutes(glance.atmakaraka.degree)} · 度数最高`, () => onSelect({ kind: "body", key: glance.atmakaraka!.key })) : null} + {glance.atmakaraka ? item("ak", "灵魂星", glance.atmakaraka.label, `本盘度数最高的星 · ${glance.atmakaraka.sign} ${formatDegreeMinutes(glance.atmakaraka.degree)}`, () => onSelect({ kind: "body", key: glance.atmakaraka!.key })) : null} {glance.dasha ? item( "dasha", "当前运期", diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index 5f79ffca..b29d48fb 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { Pencil } from "lucide-react"; @@ -316,24 +317,33 @@ export function ChartPageView({
) : (
- { - if (value) selectSystem(value as ChartSystem); - }} - > - - - 印度盘 - {journey?.tabHasDot("vedic") ? : null} - - - 西洋盘 - {journey?.tabHasDot("western") ? : null} - - - +
+
+

{view?.status === "ok" ? `${view.profile.name.trim() || "我"} · 本命盘` : "本命盘"}

+
+
+ { + if (value) selectSystem(value as ChartSystem); + }} + > + + + 印度盘 + {journey?.tabHasDot("vedic") ? : null} + + + 西洋盘 + {journey?.tabHasDot("western") ? : null} + + + + + +
+
{view == null ? : system === "western" ? ( pendingLayers?.has("western") ? ( diff --git a/frontend/tests/chart-board-20261027.test.tsx b/frontend/tests/chart-board-20261027.test.tsx index 95dd76a4..36567acd 100644 --- a/frontend/tests/chart-board-20261027.test.tsx +++ b/frontend/tests/chart-board-20261027.test.tsx @@ -208,7 +208,9 @@ test("north and south boards draw twelve houses, every body, and lines for a sel for (const style of ["north", "south"] as const) { const plain = renderToStaticMarkup(React.createElement(ChartBoardSvg, { model, style, selection: null, onSelect: () => {}, ariaLabel: "本命盘" })); assert.equal((plain.match(/class="chart-board-cell/g) ?? []).length, 12); - assert.equal((plain.match(/class="chart-board-body /g) ?? []).length, 9); + // 原值:/class="chart-board-body /(类名后总跟一个星体颜色类)。新值:/class="chart-board-body[ "]/。 + // 原因:按原型改为按庙旺/落陷着色,没有尊贵变化的星类名后面直接是引号;仍数九颗星。 + assert.equal((plain.match(/class="chart-board-body[ "]/g) ?? []).length, 9); assert.doesNotMatch(plain, /chart-board-link/); const picked = renderToStaticMarkup(React.createElement(ChartBoardSvg, { model, style, selection: { kind: "body", key: "saturn" }, onSelect: () => {}, ariaLabel: "本命盘" })); const relations = boardRelations(model, { kind: "body", key: "saturn" }); @@ -225,12 +227,13 @@ test("north board: every sign name and body sits inside its own house, and no tw 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(/^升 /, "") })); + const labels = [...markup.matchAll(/\S+ ([^<]+)/g)] + .map((match) => ({ x: Number(match[1]), y: Number(match[2]), anchor: match[3]!, text: match[4]! })); 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; + // Glyph, space and name at the 10-unit label font. + const width = 10 * (label.text.length + 2); 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}`);