Chart page back to the signed-off prototype (issue #114, round 5)
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
47f6eb4791
commit
36fd4b2c83
@@ -1,5 +1,14 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-27 — 星盘页按签过的原型改回来
|
||||
|
||||
- 盘面按原型画:每一宫写星座符号和名字,下一行写宫号(上升宫写「上升 1」);星按庙旺绿、落陷红着色,逆行写 R。
|
||||
- 盘上方的说明条回来了:上升、度数,右边是当前大运和分运;点了星或宫,这一条改写成选中的内容。
|
||||
- 图层在左(无图层、八宫点、宫力、叠加行运、虚星、特殊点、映射),北印度 / 南印度在右。
|
||||
- 页头:左边「名字 · 本命盘」,右边印度盘 / 西洋盘、「生成报告」「带这张盘去对话」。
|
||||
- 宫位主题十二宫都在,有星的带小圆点;分运小签换行排;各卡片右上角的提示语、检视的说明、日期按钮的箭头和「此刻」、「人生长卷」的年份刻度、排盘底册三列资料,都按原型。
|
||||
- Skill 版本不变。
|
||||
|
||||
## 2026-10-27 — 输入框不再发灰,会话列表清爽一些
|
||||
|
||||
- 首页和对话页的输入框:没打字时不再整块变灰、文字发淡。
|
||||
|
||||
@@ -69,3 +69,7 @@
|
||||
| 时运流年底部重复一列 Vimshottari 手风琴 | 复用旧大运页 | `ChartDashaTab secondOnly` |
|
||||
|
||||
验证见本轮提交说明与下方数字。
|
||||
|
||||
## 第五轮:按原型改回(2026-10-27 晚)
|
||||
|
||||
产品:「you did not follow prototype」。第三轮我参照竞品图改了版式,偏离了产品签过的原型 v2(说明条删了、北南切换挪进标题、宫位主题只列有星的宫、分运单行横滑、各卡提示语删了)。本轮以原型源码为准逐项对齐:页头三件、盘卡工具行与说明条、北印度画法(重心定位、符号 + 名字 + 宫号、尊贵着色、「 R」)、右栏四卡提示与样式、检视空态、深读提示、日期按钮、人生长卷年份刻度、排盘底册三列。保留第二轮产品要的标点图层(并进图层组)和第二条运法(Chara / Yogini,原型没有,但是既有能力)。为不出框偏离原型三处,写在 DESIGN.md §15。
|
||||
|
||||
@@ -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. 点「带这张盘去对话」:到对话页,右栏是本命盘。点「生成报告」:到报告中心。
|
||||
|
||||
+6
-6
@@ -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`,只随星盘页和右栏的懒加载块到达,不进首页首屏。
|
||||
|
||||
|
||||
@@ -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<readonly [ChartBoardLayer | PointGroup, string]> = [
|
||||
["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<PlanetGlyphKey, "good" | "bad"> {
|
||||
const tones = new Map<PlanetGlyphKey, "good" | "bad">();
|
||||
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({
|
||||
<Card className="chart-board-card">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">{style === "north" ? "北印度盘" : "南印度盘"}</CardTitle>
|
||||
<CardAction>
|
||||
<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={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>
|
||||
) : <span />}
|
||||
<Tabs value={style} onValueChange={(value) => value && onStyle(value as ChartBoardStyle)}>
|
||||
<TabsList variant="default" aria-label="盘式" className="chart-board-style">
|
||||
<TabsList variant="default" aria-label="盘式">
|
||||
<TabsTrigger value="north" className="chart-board-tab">北印度</TabsTrigger>
|
||||
<TabsTrigger value="south" className="chart-board-tab">南印度</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</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>
|
||||
</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}
|
||||
{"note" in caption && caption.note ? <span className="chart-board-caption-detail">· {caption.note}</span> : null}
|
||||
{"chips" in caption && caption.chips?.length ? (
|
||||
<span className="chart-board-caption-chips">
|
||||
{caption.chips.map((chip) => <Badge key={chip} variant="outline" className="chart-chip">{chip}</Badge>)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{source && model ? (
|
||||
<div
|
||||
@@ -210,6 +241,7 @@ export function ChartBoardCard({
|
||||
center={style === "south" && caption ? caption : undefined}
|
||||
pointMarks={pointMarks}
|
||||
onPointSelect={onPointSelect}
|
||||
dignity={natal ? dignityTones(view) : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : !pending && failure ? (
|
||||
@@ -218,11 +250,11 @@ export function ChartBoardCard({
|
||||
<ChartPageSkeleton copy={CHART_VIEW_COPY.waitingChart} />
|
||||
)}
|
||||
<div className="chart-board-legend">
|
||||
<span><i className="chart-board-key is-good" />入旺 / 本座</span>
|
||||
<span><i className="chart-board-key is-bad" />落陷</span>
|
||||
<span>逆 = 逆行</span>
|
||||
<span><i className="chart-board-key is-line" />它管的宫</span>
|
||||
<span><i className="chart-board-key is-dash" />它看的宫</span>
|
||||
<span><Badge variant="outline" className="chart-band is-strong">绿</Badge>入旺 / 本座</span>
|
||||
<span><Badge variant="outline" className="chart-band is-weak">红</Badge>落陷</span>
|
||||
<span>R 逆行</span>
|
||||
<span><i className="chart-board-key is-line" />宫主落点</span>
|
||||
<span><i className="chart-board-key is-dash" />视相</span>
|
||||
</div>
|
||||
{natal && activeLayer !== "none" && layerFailure && !layerPending ? (
|
||||
<p className="chart-page-note">{chartLayerFailureCopy(layerFailure)}</p>
|
||||
|
||||
@@ -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<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; 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<Record<number, "left" | "right">> = { 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<number> = new Set([6, 8]);
|
||||
const TOP_HOUSES: ReadonlySet<number> = 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<PlanetGlyphKey, "good" | "bad">;
|
||||
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 (
|
||||
<g key={`ink-${cell.house}`} className={faded ? "chart-board-ink is-faded" : "chart-board-ink"}>
|
||||
{cell.shape.kind === "rect" ? (
|
||||
<>
|
||||
<text className="chart-board-sign" x={cell.shape.x + 7} y={cell.shape.y + 15}>{signName}</text>
|
||||
<text className={isAsc ? "chart-board-house is-asc" : "chart-board-house"} x={cell.shape.x + CELL - 7} y={cell.shape.y + 15} textAnchor="end">
|
||||
<text className="chart-board-sign" x={cell.shape.x + 6} y={cell.shape.y + 14}>{`${SIGN_GLYPH[cell.sign] ?? ""} ${signName}`}</text>
|
||||
<text className={isAsc ? "chart-board-house is-asc" : "chart-board-house"} x={cell.shape.x + CELL - 6} y={cell.shape.y + 14} textAnchor="end">
|
||||
{cell.house}
|
||||
</text>
|
||||
{note ? (
|
||||
@@ -277,28 +247,33 @@ export function ChartBoardSvg({
|
||||
</text>
|
||||
) : null}
|
||||
</>
|
||||
) : slot ? (
|
||||
) : (
|
||||
<>
|
||||
<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 className="chart-board-sign" x={cx} y={labelY} textAnchor="middle">{`${SIGN_GLYPH[cell.sign] ?? ""} ${signName}`}</text>
|
||||
<text className={isAsc ? "chart-board-house is-asc" : "chart-board-house"} x={cx} y={labelY + 12} textAnchor="middle">
|
||||
{isAsc ? `上升 ${cell.house}` : cell.house}
|
||||
</text>
|
||||
{note ? (
|
||||
<text className={`chart-board-note is-${note.tone}`} x={cx} y={cell.triangle ? cy + 30 : cy + 38} textAnchor="middle">{note.text}</text>
|
||||
) : null}
|
||||
{transit.length > 0 ? (
|
||||
<text className="chart-board-transit" x={slot.transit.x} y={slot.transit.y} textAnchor="middle">
|
||||
<text className="chart-board-transit" x={cx} y={cell.triangle ? cy + 26 : cy + 30} textAnchor="middle">
|
||||
{transit.map((key) => PLANET_GLYPH_ZH[key]).join(" ")}
|
||||
</text>
|
||||
) : 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 (
|
||||
<g
|
||||
key={body.key}
|
||||
className={`chart-board-body ${PLANET_COLOR_CLASS[body.key]}${selected ? " is-selected" : ""}${dim ? " is-dim" : ""}`}
|
||||
className={`chart-board-body${tone ? ` is-${tone}` : ""}${selected ? " is-selected" : ""}${dim ? " is-dim" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${PLANET_NAME_ZH[body.key]},${signName}${Math.floor(body.degree)}度${body.retrograde ? ",逆行" : ""},第${body.house}宫`}
|
||||
@@ -312,8 +287,8 @@ export function ChartBoardSvg({
|
||||
<text className="chart-board-glyph" x={point.x} y={point.y} fontSize={layout.size} textAnchor="middle">
|
||||
{PLANET_GLYPH_ZH[body.key]}
|
||||
</text>
|
||||
<text className="chart-board-degree" x={point.x + layout.size * 0.55} y={point.y - layout.size * 0.5}>
|
||||
{`${Math.floor(body.degree)}°${body.retrograde ? "逆" : ""}`}
|
||||
<text className="chart-board-degree" x={point.x + layout.size * 0.55} y={point.y - layout.size * 0.45}>
|
||||
{`${Math.floor(body.degree)}°${body.retrograde && !node ? " R" : ""}`}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
@@ -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 (
|
||||
<g key={`marks-${cell.house}`} className="chart-board-marks">
|
||||
{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 (
|
||||
|
||||
@@ -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%); }
|
||||
|
||||
@@ -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
|
||||
<div>
|
||||
<div className="chart-period-top">
|
||||
<strong>
|
||||
{key ? <span className={`chart-graha-glyph ${PLANET_COLOR_CLASS[key]}`}>{PLANET_GLYPH_ZH[key]}</span> : null}
|
||||
{span.label}
|
||||
{key ? <span className="chart-period-glyph">{PLANET_GLYPH_ZH[key]}</span> : null}
|
||||
<small>{span.label}</small>
|
||||
</strong>
|
||||
<span>{span.start} – {span.end}</span>
|
||||
</div>
|
||||
@@ -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 ? <span className="chart-scroll-now" style={{ left: `${((t - a) / width) * 100}%` }} /> : null}
|
||||
</div>
|
||||
{axis ? (
|
||||
<div className="chart-scroll-axis" aria-hidden="true">
|
||||
{(() => {
|
||||
const from = Number(first.start.slice(0, 4));
|
||||
const to = Number(last.end.slice(0, 4));
|
||||
const years: number[] = [from];
|
||||
for (let year = Math.ceil((from + 1) / 20) * 20; year < to - 8; year += 20) years.push(year);
|
||||
years.push(to);
|
||||
return years.map((year) => {
|
||||
const at = ((Date.parse(`${year}-01-01T00:00:00Z`) - a) / width) * 100;
|
||||
return <span key={year} style={{ left: `${Math.min(100, Math.max(0, at))}%` }}>{year}</span>;
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -187,7 +203,7 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai
|
||||
}, [onNeedLayer]);
|
||||
return (
|
||||
<div className="chart-deep-body">
|
||||
<Section title="观测日期">
|
||||
<Section title="观测日期" hint="换日期,下面的运期、行运和盘上的行运图层一起变">
|
||||
<div className="chart-date-bar">
|
||||
<Input
|
||||
type="date"
|
||||
@@ -200,15 +216,15 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(event.target.value)) onObserve(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onObserve(dayShift(observe, "month", -1))}>上月</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onObserve(dayShift(observe, "month", 1))}>下月</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onObserve(dayShift(observe, "year", -1))}>上年</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onObserve(dayShift(observe, "year", 1))}>下年</Button>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={observe === today} onClick={() => onObserve(today)}>回到今天</Button>
|
||||
<Button type="button" variant="outline" size="sm" className="chart-date-step" onClick={() => onObserve(dayShift(observe, "month", -1))}><ChevronLeft aria-hidden="true" />上月</Button>
|
||||
<Button type="button" variant="outline" size="sm" className="chart-date-step" onClick={() => onObserve(dayShift(observe, "month", 1))}>下月<ChevronRight aria-hidden="true" /></Button>
|
||||
<Button type="button" variant="outline" size="sm" className="chart-date-step" onClick={() => onObserve(dayShift(observe, "year", -1))}><ChevronLeft aria-hidden="true" />上年</Button>
|
||||
<Button type="button" variant="outline" size="sm" className="chart-date-step" onClick={() => onObserve(dayShift(observe, "year", 1))}>下年<ChevronRight aria-hidden="true" /></Button>
|
||||
<Button type="button" variant="link" size="sm" disabled={observe === today} onClick={() => onObserve(today)}>此刻</Button>
|
||||
</div>
|
||||
</Section>
|
||||
<div className="chart-deep-two">
|
||||
<Section title="当前运期">
|
||||
<Section title="当前运期" hint="进度=这一层已走过多少">
|
||||
{now ? (
|
||||
<>
|
||||
<PeriodRow level="大运" span={{ lord: now.maha.lord, label: now.maha.label, start: now.maha.start, end: now.maha.end }} day={observe} />
|
||||
@@ -265,11 +281,12 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
<Section title="Vimshottari 人生长卷" hint="按真实时长等比排,点一段往下展开">
|
||||
<Section title="人生长卷" hint="按真实时长等比排;点一段往下展开">
|
||||
<div className="chart-scroll-wrap">
|
||||
<div className="chart-scroll-inner">
|
||||
<ScrollRow
|
||||
label="大运"
|
||||
axis
|
||||
range={`${periods[0]?.start.slice(0, 4) ?? ""} – ${periods.at(-1)?.end.slice(0, 4) ?? ""}`}
|
||||
spans={periods.map((item) => ({ lord: item.lord, label: item.label, start: item.start, end: item.end }))}
|
||||
day={observe}
|
||||
@@ -734,19 +751,26 @@ function BookTab({ view, board, onNeedLayer }: {
|
||||
return (
|
||||
<div className="chart-deep-body">
|
||||
<Section title="出生资料与计算口径">
|
||||
<ChartParamTable view={view} />
|
||||
<dl className="chart-book-grid is-three">
|
||||
<div><dt>出生当地时间</dt><dd>{view.profile.date} {view.profile.time}</dd></div>
|
||||
<div><dt>出生时区</dt><dd>{view.profile.timezoneLabel}</dd></div>
|
||||
<div><dt>地点</dt><dd>{view.profile.placeLabel}</dd></div>
|
||||
<div><dt>岁差</dt><dd>{view.profile.ayanamsaDisplay}</dd></div>
|
||||
<div><dt>宫制</dt><dd>整宫制</dd></div>
|
||||
<div><dt>交点口径</dt><dd>{view.profile.nodeModeLabel}</dd></div>
|
||||
</dl>
|
||||
<p className="chart-page-boundary">{view.vedic.boundary}</p>
|
||||
</Section>
|
||||
{calendar ? (
|
||||
<Section title="出生历法">
|
||||
<dl className="chart-book-grid">
|
||||
<dl className="chart-book-grid is-three">
|
||||
<div><dt>提毗</dt><dd>{calendar.tithi ?? "—"}</dd></div>
|
||||
<div><dt>月宿</dt><dd>{moon ? `${moon.nakshatra} · 第 ${moon.pada} 足` : "—"}</dd></div>
|
||||
<div><dt>星期</dt><dd>{calendar.weekday}</dd></div>
|
||||
</dl>
|
||||
</Section>
|
||||
) : null}
|
||||
<Section title="数据表">
|
||||
<Section title="数据表" hint="数据来自本站引擎">
|
||||
<Tabs value={table} onValueChange={(value) => value && setTable(value as BookTable)}>
|
||||
<TabsList variant="default" aria-label="数据表" className="chart-book-tabs">
|
||||
{BOOK_TABLES.map(([id, label]) => <TabsTrigger key={id} value={id} className="chart-board-tab">{label}</TabsTrigger>)}
|
||||
@@ -754,6 +778,7 @@ function BookTab({ view, board, onNeedLayer }: {
|
||||
</Tabs>
|
||||
{table === "planets" ? (
|
||||
<div className="chart-deep-two">
|
||||
<Section title="行星名册" hint="D1">
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table is-sticky">
|
||||
<thead>
|
||||
@@ -772,12 +797,14 @@ function BookTab({ view, board, onNeedLayer }: {
|
||||
return (
|
||||
<tr key={row.name} onClick={() => key && board.select({ kind: "body", key })}>
|
||||
<th scope="row">
|
||||
{key ? <span className={`chart-graha-glyph ${PLANET_COLOR_CLASS[key]}`}>{PLANET_GLYPH_ZH[key]}</span> : null}
|
||||
{row.label}
|
||||
</th>
|
||||
<td className="chart-graha-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)} · {row.house}宫</td>
|
||||
<td>{row.nakshatra}</td>
|
||||
<td>{dignity?.label ?? "—"}{row.retrograde ? " · 逆" : ""}</td>
|
||||
<td>
|
||||
<span className={dignity?.step === 0 ? "chart-graha-bad" : dignity && dignity.step >= 6 ? "chart-graha-good" : undefined}>{dignity?.label ?? "—"}</span>
|
||||
{row.retrograde && key !== "rahu" && key !== "ketu" ? " 逆" : ""}
|
||||
</td>
|
||||
<td>{row.d9SignLabel ?? "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -785,11 +812,13 @@ function BookTab({ view, board, onNeedLayer }: {
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="宫主流向" hint="第 N 宫之主落在哪一宫">
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">宫主流向</th>
|
||||
<th scope="col">宫位</th>
|
||||
<th scope="col">宫主</th>
|
||||
<th scope="col">落宫</th>
|
||||
</tr>
|
||||
@@ -809,6 +838,7 @@ function BookTab({ view, board, onNeedLayer }: {
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
) : null}
|
||||
{table === "upagraha" || table === "special" || table === "arudha" ? (
|
||||
@@ -861,6 +891,7 @@ export function ChartDeepReading({
|
||||
<Card className="chart-board-card chart-deep" id="chart-deep">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">深读</CardTitle>
|
||||
<CardAction className="chart-board-card-hint">时运 · 能量 · 格局 · 底册</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={tab} onValueChange={(value) => value && select(value as ChartDeepTab)}>
|
||||
|
||||
@@ -252,10 +252,12 @@ export function ChartInspector({
|
||||
<Card className="chart-board-card chart-inspector">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">盘面检视</CardTitle>
|
||||
<CardAction className="chart-board-card-hint">点星体或宫位</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="chart-inspector-empty">点盘上的星或宫看详情</p>
|
||||
<p className="chart-inspector-hint">方向键切换 · Esc 收起</p>
|
||||
<p className="chart-inspector-lead">点了之后,盘上会连出它管的宫和它看的宫,右边表格同一行也会亮。</p>
|
||||
<p className="chart-inspector-hint">方向键在九曜或十二宫间切换 · Esc 收起</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}}
|
||||
>
|
||||
<span className={`chart-graha-glyph ${PLANET_COLOR_CLASS[key]}`}>{PLANET_GLYPH_ZH[key]}</span>
|
||||
{row.label}
|
||||
{row.retrograde ? <small>逆</small> : null}
|
||||
{row.retrograde && key !== "rahu" && key !== "ketu" ? " R" : null}
|
||||
</Button>
|
||||
</th>
|
||||
<td className="chart-graha-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td>
|
||||
@@ -111,12 +110,12 @@ export function ChartHouseThemesCard({ model, selection, onSelect }: {
|
||||
<Card className="chart-board-card">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">宫位主题</CardTitle>
|
||||
<CardAction className="chart-board-card-hint">点一下,盘上对应的宫亮起</CardAction>
|
||||
</CardHeader>
|
||||
<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 occupied = (model.houses[index]?.bodies.length ?? 0) > 0;
|
||||
const on = selection?.kind === "house" && selection.house === house;
|
||||
return (
|
||||
<Button
|
||||
@@ -128,6 +127,7 @@ 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>
|
||||
);
|
||||
@@ -148,6 +148,7 @@ export function ChartDashaStripCard({ view }: { view: ChartViewOk }) {
|
||||
<Card className="chart-board-card">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">Vimshottari 大运</CardTitle>
|
||||
<CardAction className="chart-board-card-hint">点一段,下面看分运</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="chart-dasha-strip">
|
||||
@@ -172,8 +173,8 @@ export function ChartDashaStripCard({ view }: { view: ChartViewOk }) {
|
||||
{shown ? (
|
||||
<div className="chart-chip-row chart-dasha-antars" aria-label={`${shown.label}大运的分运`}>
|
||||
{shown.antardashas.map((antar) => (
|
||||
<Badge key={antar.start} variant={antar.start <= today && today < antar.end ? "default" : "outline"} className="chart-chip">
|
||||
{antar.label} {antar.start.slice(0, 7)}
|
||||
<Badge key={antar.start} variant="outline" className={antar.start <= today && today < antar.end ? "chart-chip is-on" : "chart-chip"}>
|
||||
{(() => { const key = planetGlyphKey(antar.lord); return key ? PLANET_GLYPH_ZH[key] : antar.label; })()} {antar.start.slice(0, 7).replace("-", ".")}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -208,11 +209,12 @@ export function ChartGlanceCard({ view, model, onSelect, onOpenTime }: {
|
||||
<Card className="chart-board-card">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">命盘速览</CardTitle>
|
||||
<CardAction className="chart-board-card-hint">点一项,在盘上定位</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="chart-glance">
|
||||
{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",
|
||||
"当前运期",
|
||||
|
||||
@@ -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({
|
||||
</section>
|
||||
) : (
|
||||
<div className="chart-page-body">
|
||||
<Tabs
|
||||
className="chart-page-tabs"
|
||||
value={system}
|
||||
onValueChange={(value) => {
|
||||
if (value) selectSystem(value as ChartSystem);
|
||||
}}
|
||||
>
|
||||
<TabsList variant="line" aria-label="星盘体系">
|
||||
<TabsTrigger value="vedic" disabled={view == null} className="chart-page-tab">
|
||||
印度盘
|
||||
{journey?.tabHasDot("vedic") ? <OnboardingDot playKey="tab-vedic" /> : null}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="western" disabled={view == null} className="chart-page-tab">
|
||||
西洋盘
|
||||
{journey?.tabHasDot("western") ? <OnboardingDot playKey="tab-western" /> : null}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="chart-page-head">
|
||||
<div className="chart-page-head-title">
|
||||
<h2>{view?.status === "ok" ? `${view.profile.name.trim() || "我"} · 本命盘` : "本命盘"}</h2>
|
||||
</div>
|
||||
<div className="chart-page-head-actions">
|
||||
<Tabs
|
||||
className="chart-page-tabs"
|
||||
value={system}
|
||||
onValueChange={(value) => {
|
||||
if (value) selectSystem(value as ChartSystem);
|
||||
}}
|
||||
>
|
||||
<TabsList variant="default" aria-label="星盘体系">
|
||||
<TabsTrigger value="vedic" disabled={view == null} className="chart-page-tab">
|
||||
印度盘
|
||||
{journey?.tabHasDot("vedic") ? <OnboardingDot playKey="tab-vedic" /> : null}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="western" disabled={view == null} className="chart-page-tab">
|
||||
西洋盘
|
||||
{journey?.tabHasDot("western") ? <OnboardingDot playKey="tab-western" /> : null}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button render={<Link href="/reports" />} nativeButton={false} variant="outline" className="chart-page-head-button">生成报告</Button>
|
||||
<Button render={<Link href="/" />} nativeButton={false} className="chart-page-head-button">带这张盘去对话</Button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="chart-page-panel" aria-label={system === "western" ? "西洋盘" : "印度盘"}>
|
||||
{view == null ? <ChartPageSkeleton copy={CHART_VIEW_COPY.waitingChart} /> : system === "western" ? (
|
||||
pendingLayers?.has("western") ? (
|
||||
|
||||
@@ -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(/<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(/^升 /, "") }));
|
||||
const labels = [...markup.matchAll(/<text class="chart-board-sign[^"]*" x="([\d.]+)" y="([\d.]+)" text-anchor="(\w+)">\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}`);
|
||||
|
||||
Reference in New Issue
Block a user