From e80bc64d93b95229400d9ca4bbe7c0c2bac5513a Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 27 Oct 2026 12:46:59 +0800 Subject: [PATCH] Chart page on one sheet with a clickable North/South board (issue #114) The chart page is now one page: board and inspector beside the nine-body table, house themes, dasha strip and summary, with four deep-reading tabs below (time, strength, yogas, chart book). Clicking a body or house draws its rulership and aspect lines and fills the inspector. Shadbala, yogas and the birth tithi come from the natal packet; ashtakavarga and house strength are a new lazy layer; the transit layer takes any date. The chat's natal panel carries the same board, opens by default, and is a bottom sheet on phones. Logged two engine bugs found on the way (BUG-1462 sunrise, BUG-1463 /api/shadbala); neither is fixed here. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v --- CHANGELOG.md | 13 + docs/BUG_HISTORY.md | 32 + docs/tasks/PROGRESS-chart-ui-114-20261027.md | 41 + docs/tasks/README.md | 1 + docs/testing/chart-board-114-20261027.md | 33 + frontend/DESIGN.md | 21 + frontend/src/app/(app)/chart/page.tsx | 1 + frontend/src/app/api/chart-view/route.ts | 8 +- .../chart-board/chart-board-card.tsx | 220 ++++ .../chart-board/chart-board-styles.ts | 6 + .../chart-board/chart-board-svg.tsx | 286 +++++ .../components/chart-board/chart-board.css | 324 +++++ .../chart-board/chart-deep-reading.tsx | 692 ++++++++++ .../chart-board/chart-inspector.tsx | 287 +++++ .../chart-board/chart-side-cards.tsx | 266 ++++ .../chart-board/chart-type-table.tsx | 71 ++ .../components/chart-page/chart-page-view.tsx | 260 ++-- .../components/chart-page/chart-vedic-tab.tsx | 8 +- frontend/src/components/chat-chart-frame.tsx | 43 +- frontend/src/components/chat-chart-sheet.tsx | 24 + frontend/src/components/chat-natal-chart.tsx | 128 +- frontend/src/hooks/use-chart-board.ts | 140 ++ frontend/src/hooks/use-chart-page.ts | 4 +- frontend/src/hooks/use-transit-at.ts | 68 + frontend/src/lib/chart-board-model.ts | 182 +++ frontend/src/lib/chart-board-style.ts | 34 + frontend/src/lib/chart-board-view.ts | 191 +++ frontend/src/lib/chart-view-client.ts | 3 + frontend/src/lib/chart-view-contract.ts | 89 ++ frontend/src/lib/chart-view-engine.ts | 14 +- frontend/src/lib/chart-view-insight.ts | 220 ++++ frontend/src/lib/chart-view-load.ts | 17 +- frontend/src/lib/chart-view-mapper.ts | 33 +- frontend/src/lib/chart-view-service.ts | 3 + frontend/src/lib/chat-chart-panel-state.ts | 16 +- frontend/src/lib/dasha-span.ts | 37 + frontend/src/stores/ui-store.ts | 26 +- frontend/tests/chart-board-20261027.test.tsx | 271 ++++ frontend/tests/chart-page-view.test.tsx | 42 +- frontend/tests/chat-chart-panel-state.test.ts | 19 +- .../tests/drag-width-handle-20261027.test.tsx | 5 +- .../tests/fixtures/chart-insight-golden.json | 1129 +++++++++++++++++ 42 files changed, 5118 insertions(+), 190 deletions(-) create mode 100644 docs/tasks/PROGRESS-chart-ui-114-20261027.md create mode 100644 docs/testing/chart-board-114-20261027.md create mode 100644 frontend/src/components/chart-board/chart-board-card.tsx create mode 100644 frontend/src/components/chart-board/chart-board-styles.ts create mode 100644 frontend/src/components/chart-board/chart-board-svg.tsx create mode 100644 frontend/src/components/chart-board/chart-board.css create mode 100644 frontend/src/components/chart-board/chart-deep-reading.tsx create mode 100644 frontend/src/components/chart-board/chart-inspector.tsx create mode 100644 frontend/src/components/chart-board/chart-side-cards.tsx create mode 100644 frontend/src/components/chart-board/chart-type-table.tsx create mode 100644 frontend/src/components/chat-chart-sheet.tsx create mode 100644 frontend/src/hooks/use-chart-board.ts create mode 100644 frontend/src/hooks/use-transit-at.ts create mode 100644 frontend/src/lib/chart-board-model.ts create mode 100644 frontend/src/lib/chart-board-style.ts create mode 100644 frontend/src/lib/chart-board-view.ts create mode 100644 frontend/src/lib/chart-view-insight.ts create mode 100644 frontend/src/lib/dasha-span.ts create mode 100644 frontend/tests/chart-board-20261027.test.tsx create mode 100644 frontend/tests/fixtures/chart-insight-golden.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ed14bf..f924bada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # 印度占星 Skill 更新日志 +## 2026-10-27 — 星盘页一页铺开,点星点宫都会联动(issue #114) + +- 星盘页不再分四个页签。上面是盘和「盘面检视」,旁边是九曜、宫位主题、大运和命盘速览,下面「深读」四页:时运流年、能量图谱、命性格局、排盘底册。西洋盘在页头切换。 +- 盘可以在北印度和南印度之间切换,默认北印度,全站记住你的选择。 +- 点一颗星:盘上连出它管的宫(实线)和它看的宫(虚线),九曜表同一行亮起,检视里看位置、月宿、尊贵、六力、视相、主管大运和当天行运。点宫位或宫位主题同理。方向键切换,Esc 收起。 +- 盘上可以叠八宫点、宫力或行运。六力、宫力、八宫点都给数字,旁边写强 / 中 / 弱。 +- 时运流年可以选任意日期:大运、分运、子运三层进度,九曜行运表,人生长卷可以逐层展开。 +- 能量图谱有六力分量、宫力柱状图和八宫点表(三种口径)。命性格局只列成立的经典格局,没成立的折起来。排盘底册有出生历法、行星名册和宫主流向。 +- 「问 AI:这颗星」只把问题放进输入框,要你自己按发送,不会误扣点。 +- 对话页一进来右边就有这张盘,不用先问一句;右栏能拖宽拖窄,宽度会记住。手机上点顶栏的星盘图标,从底部拉起同一张盘。 +- 引擎的日出时刻算错了几个小时(BUG-1462),排盘底册暂不显示日出。 +- Skill 版本不变。 + ## 2026-10-27 — 报告前面加一段「解读」,对话把「为什么」写进正文 - 新生成的中文报告最前面多一段「解读」:总评、性格、事业、财富、感情、健康、现在这几年、几条建议。由模型按引擎已算好的盘面来写,用「你」说话,每段说清盘上是什么、为什么、生活里是什么样子。原来的报告内容放在后面,标为「计算依据」,内容不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3e8f305b..ca940f48 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19915,3 +19915,35 @@ - 复发自:无 - 修复版本:分支 `codex/voice-reading-20261027`。 + +## BUG-1462 | 特殊上升点用的日出时刻错了几个小时 + +- 状态:investigating +- 首次发现:2026-10-27(做 issue #114 排盘底册时发现) +- 最近更新:2026-10-27 +- 影响面:`/api/chart` 的 `special_lagnas`(HL / GL / PP / ViL 与 `sunrise_local_time`、`ghatis_elapsed_from_sunrise`),以及读它的地方。 +- 用户现象:页面上暂时看不到;星盘页本来要显示的「日出」时刻错了,本轮改为不显示。 +- 触发条件:大多数日期都会偏。实测(本机引擎):北京 1990-06-15 引擎给 07:32,Swiss Ephemeris 日出 04:46;杭州 1990-04-12 引擎给 06:31,Swiss Ephemeris 05:37。 +- 根因(已确认一处):`scripts/jaimini.py` `calc_sunrise_utc_hours` 求太阳黄经时用 `mean_anomaly + center + 180.10248 + 0.000048*(jd-2451545)*360`,缺了近日点黄经 102.9372°,又多了一项随日期累积的修正,太阳黄经错成别的季节,赤纬和时角跟着错。HL / GL 等由日出后 ghati 推出,所以一起偏。 +- 修复:未修。本轮只让星盘页不读这个字段(`frontend/src/lib/chart-view-insight.ts` `mapBirthCalendar`)。改引擎会改变特殊上升点的数值,需要单开一单、跑全量 pytest 并更新金样。 +- 验证:上面两组对比数字;`frontend/tests/chart-board-20261027.test.tsx` 断言出生历法只含提毗与星期。 +- 防复发:待修复单补测试(与 Swiss Ephemeris 日出对比,误差 ≤ 2 分钟)。 +- 相关记录:issue #114。 +- 复发自:无 +- 修复版本:未修。 + +## BUG-1463 | 单独调 `/api/shadbala` 带出生资料时 500 + +- 状态:investigating +- 首次发现:2026-10-27(做 issue #114 时调接口发现) +- 最近更新:2026-10-27 +- 影响面:直接请求 `/api/shadbala` 并带 year/month/day/lat/lon 的调用方。星盘页不受影响:六力取自 `/api/chart` 的 `modules.shadbala`。 +- 用户现象:接口返回 `ERR_INTERNAL`。 +- 触发条件:请求体同时带 `year`、`month`、`day`、`lat`、`lon`。 +- 根因:`scripts/jyotish_api_server.py` `_compute_shadbala` 调 `swe.julday`,但该文件只在别的函数内部局部 `import swisseph as swe`,模块级没有 `swe`,抛 `NameError`。 +- 修复:未修。主文件受增长冻结约束,修复单需另开(把这段逻辑移到 `scripts/` 下的模块里再改)。 +- 验证:本机 `POST /api/shadbala` 带出生资料 → 500,日志 `NameError: name 'swe' is not defined`。 +- 防复发:待修复单补接口测试。 +- 相关记录:issue #114。 +- 复发自:无 +- 修复版本:未修。 diff --git a/docs/tasks/PROGRESS-chart-ui-114-20261027.md b/docs/tasks/PROGRESS-chart-ui-114-20261027.md new file mode 100644 index 00000000..5e34d533 --- /dev/null +++ b/docs/tasks/PROGRESS-chart-ui-114-20261027.md @@ -0,0 +1,41 @@ +# PROGRESS · 星盘页一页铺开与点选联动(issue #114,2026-10-27) + +直接执行模式:产品 2026-10-27 在会话里说「这单你来做,不交给 Grok」。没有单独任务书,范围与决策以 issue #114 评论(Q1~Q9 + 原型三件小事)和 `docs/research/chart_ui_vs_competitor_2026_10_27.md` 为准。原型:https://claude.ai/artifact/55AgsjsV8cFRobtM4d2cKz + +- 基线:`origin/staging` 8e05e4114;分支 `codex/chart-ui-114-20261027`。 +- 决策(产品定):Q1 B 配色不变只学圆角卡;Q2 A 一页铺开;Q3 南北都做默认北;Q4 西洋盘保留;Q5 数字 + 强中弱;Q6 未满足收起;Q7 问 AI 只填输入框;Q8 A 进对话就有盘 + 右栏可拖宽;Q9 观测日期任意。三件小事按原型默认:手机底部拉起、右栏宽度记住、南北全站统一。 +- 推翻的既有决定(已写进 DESIGN.md §15 新节):星盘页四页签结构;盘里只放彩色符号(2026-09-18)→ 星盘页盘面改中文一字星名 + 度数(报告、校正右栏仍用 `VedicChartSvg`);对话右栏默认收起、手机没有入口;右栏宽 280–560 → 320–760。 + +## 做了什么 + +| 块 | 内容 | 位置 | +| --- | --- | --- | +| 数据 | 六力(`modules.shadbala` 六分量)、闭合清单瑜伽、出生历法(提毗 + 星期)直接随 `/api/chart` 映射;新懒层 `ashtakavarga`(`/api/ashtakavarga` + `/api/bhava_bala`);`transitDate` 查询参数(1000–2999 任意日) | `lib/chart-view-insight.ts`、`chart-view-contract.ts`、`chart-view-load.ts`、`chart-view-engine.ts`、`app/api/chart-view/route.ts` | +| M1/M2 | 南北印度可点盘、图层、宫主实线 / 视相虚线、检视面板、键盘切换 | `components/chart-board/*`、`lib/chart-board-model.ts`、`hooks/use-chart-board.ts` | +| M3 | 页头小签、命盘速览、九曜表、宫位主题、大运条 | `chart-side-cards.tsx` | +| M4 | 观测日期、三层进度(子运同一比例公式 `lib/dasha-span.ts`)、行运表 + 土星七年半、人生长卷、双轨对照 | `chart-deep-reading.tsx`、`hooks/use-transit-at.ts` | +| M5–M7 | 能量图谱、命性格局、排盘底册(含宫主流向) | `chart-deep-reading.tsx` | +| M8 | 对话右栏完整盘面、默认打开、拖宽记住;手机底部抽屉 | `chat-natal-chart.tsx`、`chat-chart-frame.tsx`、`chat-chart-sheet.tsx` | + +## 验证 + +- `tsc --noEmit` 0;`eslint` 改动文件 0 问题。 +- 新测试 `frontend/tests/chart-board-20261027.test.tsx` 16 条(金样 `tests/fixtures/chart-insight-golden.json` 来自本机真实引擎,`sourceCommand` 写明)。子运与引擎 `five_levels.pratyantar` 当日结果一致。 +- 改动的既有断言(三栏说明写在测试里):`chart-page-view.test.tsx` 5 处、`chat-chart-panel-state.test.ts` 3 处、`drag-width-handle-20261027.test.tsx` 1 处。 +- 全量 `npm test`:5235 条(基线 5219),失败 26 条=基线 25 条无 Docker 的库/部署测试 + `rectification-latency-20260926` 一条负载下超时(单跑在本分支与基线都通过)。消失的 3 个测试名是带三栏说明改名的。 +- `next build` 通过,`/` 仍 ○ Static,`/chart` ○;首屏 gzip 699696 → 700870(+0.17%)。 +- 本机引擎实测:`/api/ashtakavarga`、`/api/bhava_bala`、`/api/dasha/chara`、行运 `/api/chart` 各约 0.05 s。 + +## 本轮发现、未修 + +- BUG-1462:`special_lagnas` 日出偏几个小时(`scripts/jaimini.py` 太阳黄经公式错)。星盘页不读;报告事实表读这一字段,可能受影响。需单开修复单。 +- BUG-1463:单独调 `/api/shadbala` 带出生资料时 500(`swe` 未导入)。星盘页不受影响。 + +## 环境缺口 + +- 无登录态与 Chrome:浏览器级真机走查见 `docs/testing/chart-board-114-20261027.md`。 +- 竞品「交界与结构联系」、出生历法的羯罗那 / 常瑜伽 / 日出日落 / 下次交界:引擎没有出生时刻的这些值,本轮不做。 + +## 遗留 + +- `chart-vedic-tab.tsx` 里的 `ChartVedicTab` / `VedicLayout`(旧盘面分栏与换盘动效)和 `chart-basics-tab.tsx` 已不被页面使用,仍有 10 个测试文件在测它们;清理需另开一轮连测试一起改。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 58f9e02f..8bad42d4 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -540,3 +540,4 @@ | `TASK-lagna-precheck-20261027.md` | — | **#112 选 A**:星盘页页头告诉用户上升在其时间精度内会不会换(复用现成的 D1 窗口扫描,纯计算);校正开场说清能做 / 不能做(BUG-1444、BUG-1445) | 待执行 | `codex/lagna-precheck-20261027` | | `TASK-lagna-precheck-fix-20261027.md` | — | **#112 第 1 次打回**:已有进行中的校正时点链接报冲突(应走首页续上);别人的星盘页不该给校正链接 | 待执行 | `codex/lagna-precheck-20261027` | | `TASK-voice-reading-20261027.md` | `PROGRESS-voice-reading-20261027.md` | **#113 选 R1 C1**:报告前面加一段模型写的「解读」(八节,素材只用引擎事实,失败不影响报告);对话依据写进正文、宫位带生活领域、时间最多三件(BUG-1460、BUG-1461) | Claude 直接执行,待验收 | `codex/voice-reading-20261027` | +| — | `PROGRESS-chart-ui-114-20261027.md` | **#114 星盘页一页铺开**:南北印度可点盘、检视、深读四页、对话右栏完整盘面(直接执行) | 待推 staging | `codex/chart-ui-114-20261027` | diff --git a/docs/testing/chart-board-114-20261027.md b/docs/testing/chart-board-114-20261027.md new file mode 100644 index 00000000..13c25da7 --- /dev/null +++ b/docs/testing/chart-board-114-20261027.md @@ -0,0 +1,33 @@ +# 星盘页一页铺开 · 真机验收(issue #114,2026-10-27) + +在 staging 用自己的账号照做。每条写「过 / 不过 + 截图」。 + +## 星盘页(电脑) + +1. 打开「星盘」。上面是「印度盘 / 西洋盘」,下面一行四枚小签(上升、月亮、太阳、当前大运)。左边是盘和「盘面检视」,右边是九曜、宫位主题、Vimshottari 大运、命盘速览,最下面「深读」。 +2. 盘默认是北印度。点「南印度」,盘换成方格,盘中央写「上升 某座」。刷新页面,还是南印度;打开对话页右栏,也是南印度。切回北印度。 +3. 点盘上的「土」:它所在的宫变浅色描边,实线连到它管的宫,虚线连到它看的宫,其他宫变淡;右边九曜表「土星」那行亮;检视里有位置条、月宿、尊贵九格、六力(数字 + 强/中/弱)、视相、它管的宫、主管大运、此刻行运。 +4. 按键盘右方向键,切到下一颗星;按 Esc,全部收起。 +5. 点「宫位主题」里的「第 7 宫」:盘上第 7 宫亮,实线连到它的宫主所在的宫;检视写宫主、宫内星、看这一宫的星、宫力、八宫点。 +6. 图层依次点「八宫点」「宫力」「叠加行运」,每宫出现数字或行运小字,下面一行灰字说明;切到 D9,图层这一排消失。 +7. 检视里点「在 D9 定位」,盘换成 D9,同一颗星仍选中。 +8. 检视里点「问 AI:这颗星」:跳到对话页,输入框里已经是一句问题,没有发出去,点数没变。 + +## 深读 + +9. 时运流年:日期框改成 2035-01-01,三层进度条、九曜行运表一起变;点「上年」「下月」「回到今天」都正常。改日期后点「叠到盘面」,盘上行运小字是那一天的。 +10. 人生长卷:点一段大运,第二行换成它的分运;点一段分运,第三行换成子运。下面「双轨对照」里点 Yogini 正常。 +11. 能量图谱:六力每星一条和六色分量;宫力柱状图点一根柱子,盘上那一宫亮;八宫点表切「按星座」「三角削减」「两步削减」,数字会变。 +12. 命性格局:只列成立的格局;点一条,盘上只留这几颗星的颜色;「未满足 N 条」默认折起。 +13. 排盘底册:出生资料表、出生历法(提毗、月宿、星期)、行星名册(有 D9 列)、宫主流向(每宫都有,没有「未提供」)。 + +## 对话页 + +14. 电脑:新开对话,还没发任何话,右边已经有盘。拖右栏左边缘变宽变窄,刷新后宽度还在;拖到最窄以下会收起,顶栏按钮能再打开。 +15. 右栏里点一颗星 → 检视 →「问 AI:这颗星」:问题出现在输入框并聚焦,没有发出。 +16. 手机:顶栏有星盘图标,点它从底部拉起盘面;点「问 AI」后抽屉收起、问题在输入框里。 + +## 不该出现的 + +- 任何时候都没有转圈或「正在加载」字样(盘型懒加载的空盘骨架除外)。 +- 375px 宽的手机上页面不横向滚动(表格、长卷在自己的框里横滚)。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index b5aac4fb..399edb4f 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1036,6 +1036,27 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 ## 15. 只读星盘页 +### 一页铺开与点选联动(2026-10-27,issue #114,推翻下文的四页签结构) + +产品 2026-10-27 拍板(issue #114 Q1–Q9):配色仍用本站的(Q1 B),只学竞品的圆角白卡;一页铺开(Q2 A);南北印度都做、默认北印度(Q3);西洋盘保留(Q4);数字照给并在旁边写「强 / 中 / 弱」(Q5);未满足的瑜伽收起(Q6);「问 AI」只填输入框(Q7);对话一进来右栏就有盘、右栏可拖宽(Q8 A);时运流年的观测日期任意(Q9)。下文「四个 Tab」「行星表在盘下」「盘里只放彩色行星符号」三条对星盘页不再成立,报告与校正右栏仍用 `VedicChartSvg` 不变。 + +- **页头**:出生资料一行不变;下面是「印度盘 / 西洋盘」分段 `Tabs`(`variant="default"`),西洋盘页原样保留。印度盘之下一行四枚圆角小签:上升 / 月亮 / 太阳 / 当前大运,点了在盘上定位,再点取消。 +- **两栏**(≥1024px,左约 53%、右约 47%;更窄时单列):左列「盘」卡 + 「盘面检视」卡 + 当前盘型自己的表(D1 没有这张,九曜卡就是本命表);右列「九曜」「宫位主题」「Vimshottari 大运」「命盘速览」四张卡。卡片白底(`--color-canvas`)、16px 圆角、一条发丝阴影,标题宋体 16px,不加红色竖条。 +- **盘卡**:标题「北印度盘 / 南印度盘」,右侧小字「宫位固定 / 星座固定 · 整宫制」。23 种盘型条照旧。下面一行左边四个图层(无图层 / 八宫点 / 宫力 / 叠加行运,只在本命 D1 出现),右边北印度 / 南印度,两组都是分段 `Tabs`。北印度盘上方一条浅底说明条写「本命盘 D1 / 上升 天秤 / 度数 / 当前大运 · 分运」,选中后改写选中的星或宫;南印度把这三行写在盘中央。南北的选择全站只记一份(`localStorage` `chart_board_style`),默认北。 +- **盘面**(`ChartBoardSvg`,`components/chart-board/`):星体画一字中文名(日月火水木金土罗计)+ 右上小号度数,逆行在度数后加「逆」(产品签过的原型,取代 2026-09-18 起的彩色符号;颜色仍按 `PLANET_COLOR_CLASS` 区分星体,不表示吉凶)。北印度宫内写星座名,上升宫写「升 天秤」并用 `--color-action`;南印度格内左上星座、右上宫号,上升格左上一道斜线。点星:所在宫 `--color-action-soft` 底 + 描边,它管的宫实线、它看的宫虚线(整宫计,火 4/7/8、木 5/7/9、土 3/10/7、罗计 5/7/9),连到的宫浅底,其余宫字淡到 .28。点宫:实线连宫主所在,虚线连看这一宫的星。 +- **图层**:八宫点在每宫写「N点」,28 及以上 `--color-action`,以下土星色;宫力写引擎宫力数,高于本盘十二宫均值一成偏暖、低一成偏冷;叠加行运把观测日的行运星用土星色小字写在对应星座。图层下一行灰字说明参考线。 +- **盘面检视**:空时一句「点盘上的星或宫看详情」+「方向键切换 · 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 宫 · 主题」,有星的前面一个小圆点。**大运卡**:一排大运小卡,当前那张主色描边,点哪张下面列它的分运小签。**命盘速览**:两列六格(上升、月亮 · 月宿、灵魂星、当前运期带进度条、六力最强、需留意),点一格在盘上定位,点运期滚到深读。 +- **深读**(`#chart-deep`):四个分段页签「时运流年(带当前大运 · 分运字)/ 能量图谱 / 命性格局(带成立组数)/ 排盘底册」,默认时运流年。 + - 时运流年:观测日期(日期框 + 上月 / 下月 / 上年 / 下年 / 回到今天,1000–2999 年任意日);当前运期三层进度条(大运、分运、子运;子运用与分运同一个比例公式,`lib/dasha-span.ts`);九曜行运表(星座、上升起、月亮起、该座八宫点 + 强中弱;表头右侧写土星七年半第几段或未激活);「叠到盘面」;Vimshottari 人生长卷三行等比条(大运 → 点开的分运 → 子运,当前段主色底、观测日一根竖线,620px 以下横滚);最下面「双轨对照」原大运页的 Chara / Yogini 对照照旧。 + - 能量图谱:行星六力(总量条 + 竖线门槛 + 六段分量彩条,右侧百分比、强中弱、鲁帕);宫位力量柱状图(均值虚线,点柱选宫);八宫点矩阵(按宫位 / 按星座,原始 / 三角削减 / 两步削减,格子按点数深浅,末行合计)。各自「叠到盘面」。 + - 命性格局:只列与对话卡同一张闭合清单(五大人物、象狮、落陷取消、日水同宫)里成立的,引擎报了削弱的写「部分满足」;没成立的折叠在「未满足 N 条」里。点一条,切回 D1、盘上只留这几颗星的颜色。底部一句「传统格局,本站未验证,只作参考」。 + - 排盘底册:参数键值表 + 边界句(原样);出生历法(提毗、月宿、星期);行星名册(含 D9 列);宫主流向(第 N 宫之主落第几宫)。 +- **等待与失败**:揭幕后不出现骨架或「正在…」:盘型懒层沿用盘位骨架(BUG-1016 例外);八宫点、行运、Chara 未到时那一块留空,失败时写原因句。 +- **对话右栏**(`ChatNatalChart`):标题「名字 · 本命盘」+「打开星盘页」,下面小签、盘卡、检视、九曜(不带六力列)。桌面默认打开(`chat_chart_panel_open` 没存过即为开),宽 320–760、默认 440,拖到最窄以下收起,宽度记在本机。手机顶栏多一个「看本命盘」图标按钮,从底部拉起同一套盘面(`Drawer`,86dvh),「问 AI」会先收起抽屉再聚焦输入框。样式在 `chart-board.css`,只随星盘页和右栏的懒加载块到达,不进首页首屏。 + + 独立 route `/chart`,不进 `page.tsx`。外壳先画、主盘先到、其余 tab 按需。不扣点、不调模型。盘面从现有路径复用 `VedicChartSvg`,不搬家。 `/chart` 是静态壳:首字节不取决于引擎。进页只请求 `/api/chart`。分盘(`/api/varga_full`,D2–D60 一次取齐)、大运 Chara(`/api/dasha/chara`,点开大运 Tab 才请求)、Yogini(`/api/dasha`,缓存范围 `dasha:yogini`,只在右栏切到 Yogini 时请求)、西洋(`/api/western`)、Bhava(`/api/bhava_chalit`)、行运(按当天日期的 `/api/chart`)在用户点到对应 tab / 盘型 / 大运体系时才请求。月亮盘从已到的 D1 投影,不发请求。未点的不发请求。 diff --git a/frontend/src/app/(app)/chart/page.tsx b/frontend/src/app/(app)/chart/page.tsx index 8323833e..76b7b5db 100644 --- a/frontend/src/app/(app)/chart/page.tsx +++ b/frontend/src/app/(app)/chart/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; +import "@/components/chart-board/chart-board-styles"; import { ChartPageRoute } from "@/components/chart-page/chart-page-view"; export const metadata: Metadata = { diff --git a/frontend/src/app/api/chart-view/route.ts b/frontend/src/app/api/chart-view/route.ts index 9fa06e4d..8f381658 100644 --- a/frontend/src/app/api/chart-view/route.ts +++ b/frontend/src/app/api/chart-view/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { parseChartViewLayers } from "@/lib/chart-view-engine"; +import { parseChartViewLayers, parseTransitDate } from "@/lib/chart-view-engine"; import { loadChartView } from "@/lib/chart-view-service"; export const runtime = "nodejs"; @@ -9,6 +9,10 @@ export const maxDuration = 20; export async function GET(request: Request) { const url = new URL(request.url); const layers = parseChartViewLayers(url.searchParams.get("layers")); - const result = await loadChartView({ layers, subjectId: url.searchParams.get("subject") }); + const result = await loadChartView({ + layers, + subjectId: url.searchParams.get("subject"), + transitDate: parseTransitDate(url.searchParams.get("transitDate")), + }); return NextResponse.json(result.body, { status: result.httpStatus }); } diff --git a/frontend/src/components/chart-board/chart-board-card.tsx b/frontend/src/components/chart-board/chart-board-card.tsx new file mode 100644 index 00000000..d0f8eacf --- /dev/null +++ b/frontend/src/components/chart-board/chart-board-card.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +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 { + BAND_ZH, + bhavaBand, + bhavaByHouse, + bhavaMean, + HOUSE_THEME_ZH, + savBand, + savByHouse, + SAV_REFERENCE, + signLabelAt, + transitBySign, + vimshottariAt, + type BoardSource, + type ChartBoardLayer, +} 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 { formatDegreeMinutes } from "@/components/chart-page/chart-format"; + +import { ChartBoardSvg, type ChartBoardHouseNote, type ChartBoardStyle } from "./chart-board-svg"; + +const LAYERS: ReadonlyArray = [ + ["none", "无图层"], + ["sav", "八宫点"], + ["bhava", "宫力"], + ["transit", "叠加行运"], +]; + +function houseNotes(view: ChartViewOk, model: BoardModel, layer: ChartBoardLayer): (ChartBoardHouseNote | null)[] | undefined { + if (layer === "sav") { + return savByHouse(view, model).map((points) => (points === null ? null : { + text: `${points}点`, + tone: points >= SAV_REFERENCE ? "high" : "low", + })); + } + if (layer === "bhava") { + const mean = bhavaMean(view); + return bhavaByHouse(view).map((total) => (total === null || mean === null ? null : { + text: String(Math.round(total)), + tone: bhavaBand(total, mean) === "strong" ? "high" : bhavaBand(total, mean) === "weak" ? "low" : "plain", + })); + } + return undefined; +} + +/** The North Indian board's heading strip; the South board writes the same thing in its centre. */ +export function boardCaption(view: ChartViewOk, source: BoardSource, model: BoardModel, selection: BoardSelection) { + if (selection?.kind === "body") { + const house = bodyHouse(model, selection.key); + const body = house ? model.houses[house - 1]?.bodies.find((item) => item.key === selection.key) : undefined; + return { + kicker: `${source.natal ? "D1" : source.title} · 选中`, + title: PLANET_NAME_ZH[selection.key], + detail: body ? `${signLabelAt(body.sign)} ${formatDegreeMinutes(body.degree)} · 第${body.house}宫` : "", + }; + } + if (selection?.kind === "house") { + const sign = model.houses[selection.house - 1]?.sign ?? -1; + return { + kicker: `${source.natal ? "D1" : source.title} · 选中`, + title: `第${selection.house}宫`, + detail: `${signLabelAt(sign)} · ${HOUSE_THEME_ZH[selection.house - 1]}`, + }; + } + const asc = model.houses[model.ascendantHouse - 1]; + const now = vimshottariAt(view, view.dasha.asOf); + return { + kicker: source.title, + 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, + }; +} + +export function ChartBoardCard({ + view, + type, + onType, + source, + model, + layer, + onLayer, + style, + onStyle, + selection, + onSelect, + pending, + failure, + layerPending, + layerFailure, + dimmedBodies, + transit, + transitPending = false, + compact = false, +}: { + view: ChartViewOk; + type: ChartTypeId; + onType: (type: ChartTypeId) => void; + source: BoardSource | null; + model: BoardModel | null; + layer: ChartBoardLayer; + onLayer: (layer: ChartBoardLayer) => void; + style: ChartBoardStyle; + onStyle: (style: ChartBoardStyle) => void; + selection: BoardSelection; + onSelect: (selection: BoardSelection) => void; + /** The chart type's own lazy layer. */ + pending: boolean; + failure?: ChartViewFailureStatus; + /** The overlay's lazy layer (ashtakavarga or transit). */ + layerPending: boolean; + layerFailure?: ChartViewFailureStatus; + dimmedBodies?: ReadonlySet; + /** The observation day's sky for the 叠加行运 layer. */ + transit: ChartViewTransitOk | null; + transitPending?: boolean; + compact?: boolean; +}) { + const natal = source?.natal ?? false; + const activeLayer = natal ? layer : "none"; + const caption = source && model ? boardCaption(view, source, model, selection) : null; + const notes = source && model && natal ? houseNotes(view, model, activeLayer) : undefined; + const overlay = activeLayer === "transit" ? transitBySign(transit) : []; + const mean = bhavaMean(view); + return ( + + + {style === "north" ? "北印度盘" : "南印度盘"} + {style === "north" ? "宫位固定" : "星座固定"} · 整宫制 + + + +
+ {natal ? ( + value && onLayer(value as ChartBoardLayer)}> + + {LAYERS.map(([id, label]) => ( + {label} + ))} + + + ) : } + value && onStyle(value as ChartBoardStyle)}> + + 北印度 + 南印度 + + +
+ {style === "north" && caption ? ( +
+ {caption.kicker} + {caption.title} + {caption.detail ? {caption.detail} : null} + {"footer" in caption && caption.footer ? {caption.footer} : null} +
+ ) : null} + {source && model ? ( +
+ ({ key, sign }))} + dimmedBodies={dimmedBodies} + center={style === "south" && caption ? caption : undefined} + /> +
+ ) : !pending && failure ? ( +

{chartLayerFailureCopy(failure)}

+ ) : ( + + )} +
+ 入旺 / 本座 + 落陷 + 逆 = 逆行 + 它管的宫 + 它看的宫 +
+ {natal && activeLayer !== "none" && layerFailure && !layerPending ? ( +

{chartLayerFailureCopy(layerFailure)}

+ ) : null} + {natal && activeLayer === "sav" && !layerFailure ? ( +

八宫点:{SAV_REFERENCE} 点及以上偏暖,以下偏冷。{SAV_REFERENCE} 是传统参考线,不是本盘均值。

+ ) : null} + {natal && activeLayer === "bhava" && mean !== null ? ( +

宫力:本盘十二宫均值 {Math.round(mean)},高出一成记「{BAND_ZH.strong}」,低一成记「{BAND_ZH.weak}」。

+ ) : null} + {natal && activeLayer === "transit" && transit ? ( +

蓝字是 {transit.date} 的行运位置。

+ ) : null} + {natal && activeLayer === "transit" && !transit && !transitPending ? ( +

这一天的行运暂时没算出来,稍后再试。

+ ) : null} +
+
+ ); +} + +export function savBandLabel(points: number): string { + return BAND_ZH[savBand(points)]; +} diff --git a/frontend/src/components/chart-board/chart-board-styles.ts b/frontend/src/components/chart-board/chart-board-styles.ts new file mode 100644 index 00000000..91382fe8 --- /dev/null +++ b/frontend/src/components/chart-board/chart-board-styles.ts @@ -0,0 +1,6 @@ +/** + * The board's stylesheet, as its own side-effect module: the chart route and + * the chat panel's lazy loaders import it, so it never joins the first paint + * of `/` and never reaches the Node test runner (which cannot load .css). + */ +import "./chart-board.css"; diff --git a/frontend/src/components/chart-board/chart-board-svg.tsx b/frontend/src/components/chart-board/chart-board-svg.tsx new file mode 100644 index 00000000..219ed3de --- /dev/null +++ b/frontend/src/components/chart-board/chart-board-svg.tsx @@ -0,0 +1,286 @@ +"use client"; + +/** + * The clickable chart (issue #114). North Indian (houses fixed, the page + * default) or South Indian (signs fixed); a body or a house can be selected, + * and the board then lights the houses it rules and aspects and draws the + * lines. Layers write one number per house (SAV, house strength) or lay the + * transit bodies over the natal ones. + * + * Bodies are the one-character Chinese names with the in-sign degree, as in + * the prototype the product owner signed off (issue #114, 2026-10-27). + */ + +import type { KeyboardEvent } from "react"; + + +import type { ChartBoardStyle } from "@/lib/chart-board-style"; + +import { + boardRelations, + type BoardModel, + type BoardSelection, +} from "@/lib/chart-board-model"; +import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels"; +import { + CHART_SIZE, + HOUSE_POLYGONS, + occupantAnchor, + polygonCentroid, + polygonPoints, + signNumberAnchor, + type Point, +} from "@/lib/north-indian-chart-geometry"; +import { PLANET_COLOR_CLASS, PLANET_GLYPH_ZH, PLANET_NAME_ZH, type PlanetGlyphKey } from "@/lib/planet-glyphs"; + +export type { ChartBoardStyle } from "@/lib/chart-board-style"; + +/** One number written in a house corner; `tone` colours it against its reference. */ +export type ChartBoardHouseNote = { text: string; tone: "high" | "low" | "plain" }; + +export type ChartBoardOverlay = { key: PlanetGlyphKey; sign: number }; + +/** Sign cell of the South Indian board: column, row on a 4 × 4 grid. */ +const SOUTH_CELL: Readonly> = { + 11: [0, 0], 0: [1, 0], 1: [2, 0], 2: [3, 0], + 10: [0, 1], 3: [3, 1], + 9: [0, 2], 4: [3, 2], + 8: [0, 3], 7: [1, 3], 6: [2, 3], 5: [3, 3], +}; +const CELL = CHART_SIZE / 4; + +type Cell = { + house: number; + sign: number; + shape: { kind: "polygon"; points: readonly Point[] } | { kind: "rect"; x: number; y: number }; + /** Where the bodies cluster. */ + anchor: Point; + /** Where lines meet the house. */ + hub: Point; + triangle: boolean; +}; + +function cellsFor(model: BoardModel, style: ChartBoardStyle): Cell[] { + return model.houses.map((house) => { + if (style === "north") { + const points = HOUSE_POLYGONS[house.house]!; + return { + house: house.house, + sign: house.sign, + shape: { kind: "polygon" as const, points }, + anchor: occupantAnchor(house.house), + hub: polygonCentroid(points), + triangle: points.length === 3, + }; + } + const [col, row] = SOUTH_CELL[house.sign] ?? [0, 0]; + return { + house: house.house, + sign: house.sign, + shape: { kind: "rect" as const, x: col * CELL, y: row * CELL }, + anchor: { x: col * CELL + CELL / 2, y: row * CELL + CELL / 2 + 4 }, + hub: { x: col * CELL + CELL / 2, y: row * CELL + CELL / 2 }, + triangle: false, + }; + }); +} + +function bodyLayout(count: number, anchor: Point, triangle: boolean): { size: number; points: Point[] } { + const size = triangle ? (count > 2 ? 15 : 18) : count > 4 ? 16 : count > 2 ? 19 : 22; + const perRow = count <= 2 ? Math.max(count, 1) : count <= 4 ? 2 : 3; + const rows = Math.ceil(count / perRow); + const stepX = size * 1.95; + const stepY = size * 1.3; + const points = Array.from({ length: count }, (_, index) => { + const row = Math.floor(index / perRow); + const inRow = Math.min(perRow, count - row * perRow); + const col = index % perRow; + return { + x: anchor.x + (col - (inRow - 1) / 2) * stepX - size * 0.18, + y: anchor.y + (row - (rows - 1) / 2) * stepY + size * 0.35, + }; + }); + return { size, points }; +} + +function activateOnKey(event: KeyboardEvent, run: () => void) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + run(); + } +} + +export function ChartBoardSvg({ + model, + style, + selection, + onSelect, + ariaLabel, + houseNotes, + overlay = [], + dimmedBodies, + center, +}: { + model: BoardModel; + style: ChartBoardStyle; + selection: BoardSelection; + onSelect: (selection: BoardSelection) => void; + ariaLabel: string; + /** Indexed by house − 1. */ + houseNotes?: readonly (ChartBoardHouseNote | null)[]; + overlay?: readonly ChartBoardOverlay[]; + /** Bodies drawn faint (a yoga picked in the deep reading keeps only its own). */ + dimmedBodies?: ReadonlySet; + /** South Indian centre: three lines. */ + center?: { kicker: string; title: string; detail: string; footer?: string }; +}) { + const cells = cellsFor(model, style); + const byHouse = new Map(cells.map((cell) => [cell.house, cell])); + const relations = boardRelations(model, selection); + const hasSelection = relations.focus !== null; + const selectedKey = selection?.kind === "body" ? selection.key : null; + const overlayBySign = new Map(); + for (const item of overlay) overlayBySign.set(item.sign, [...(overlayBySign.get(item.sign) ?? []), item.key]); + const focusHub = relations.focus !== null ? byHouse.get(relations.focus)?.hub : undefined; + + return ( + + {style === "south" ? ( + + ) : null} + {cells.map((cell) => { + const lit = relations.lit.has(cell.house); + const state = relations.focus === cell.house ? "is-selected" : lit ? "is-related" : hasSelection ? "is-dimmed" : ""; + const label = `第${cell.house}宫,${signZh(SIGN_ORDER[cell.sign] ?? "")}`; + const select = () => onSelect(selection?.kind === "house" && selection.house === cell.house ? null : { kind: "house", house: cell.house }); + return ( + activateOnKey(event, select)} + > + {cell.shape.kind === "polygon" + ? + : } + + ); + })} + {style === "south" ? (() => { + const asc = cells.find((cell) => cell.house === model.ascendantHouse); + if (!asc || asc.shape.kind !== "rect") return null; + return ; + })() : null} + {cells.map((cell) => { + const house = model.houses[cell.house - 1]!; + const faded = hasSelection && !relations.lit.has(cell.house); + const note = houseNotes?.[cell.house - 1] ?? null; + const signName = signZh(SIGN_ORDER[cell.sign] ?? ""); + const isAsc = cell.house === model.ascendantHouse; + const labelAt = style === "north" ? signNumberAnchor(cell.house) : null; + const layout = bodyLayout(house.bodies.length, cell.anchor, cell.triangle); + const transit = overlayBySign.get(cell.sign) ?? []; + return ( + + {cell.shape.kind === "rect" ? ( + <> + {signName} + + {cell.house} + + {note ? ( + {note.text} + ) : null} + {transit.length > 0 ? ( + + {transit.map((key) => PLANET_GLYPH_ZH[key]).join(" ")} + + ) : null} + + ) : labelAt ? ( + <> + + {isAsc ? `升 ${signName}` : signName} + + {note ? ( + cell.hub.y ? -22 : 30) : cell.hub.y + 40} textAnchor="middle">{note.text} + ) : null} + {transit.length > 0 ? ( + cell.hub.y ? -10 : 42) : cell.hub.y - 34} textAnchor="middle"> + {transit.map((key) => PLANET_GLYPH_ZH[key]).join(" ")} + + ) : null} + + ) : null} + {house.bodies.map((body, index) => { + const point = layout.points[index]!; + const selected = selectedKey === body.key; + const dim = dimmedBodies?.has(body.key) ?? false; + const select = () => onSelect(selected ? null : { kind: "body", key: body.key }); + return ( + { + event.stopPropagation(); + select(); + }} + onKeyDown={(event) => activateOnKey(event, select)} + > + + {PLANET_GLYPH_ZH[body.key]} + + + {`${Math.floor(body.degree)}°${body.retrograde ? "逆" : ""}`} + + + ); + })} + + ); + })} + {focusHub ? ( + + ) : null} + {style === "south" && center ? ( + + ) : null} + + ); +} diff --git a/frontend/src/components/chart-board/chart-board.css b/frontend/src/components/chart-board/chart-board.css new file mode 100644 index 00000000..317d3dfa --- /dev/null +++ b/frontend/src/components/chart-board/chart-board.css @@ -0,0 +1,324 @@ +/* Chart board, inspector, side cards and 深读 (issue #114). + Our own palette (Q1 B); only the rounded white cards come from the reference. + Loaded with the chart page and with the chat panel, never on the first paint of `/`. */ + +.chart-page-panel:has(.chart-workspace) { width: min(1240px, 100%); } + +.chart-workspace { display: grid; gap: var(--space-4); margin-top: var(--space-4); } +.chart-workspace-grid { + display: grid; + grid-template-columns: minmax(0, 1.12fr) minmax(0, 1fr); + gap: var(--space-4); + align-items: start; +} +.chart-workspace-col { display: grid; gap: var(--space-4); min-width: 0; scroll-margin-top: var(--space-16); } +@media (max-width: 1023px) { + .chart-workspace-grid { grid-template-columns: minmax(0, 1fr); } +} + +/* Cards: white on the canvas, one hairline, 16px radius. */ +.chart-board-card { + background: var(--color-canvas); + border-radius: 16px; + box-shadow: 0 1px 2px color-mix(in srgb, var(--color-ink) 4%, transparent); + min-width: 0; +} +.chart-board-card-header { align-items: baseline; } +.chart-board-card-title { font-family: var(--font-display); font-size: 16px; font-weight: 600; } +.chart-board-card-hint { font-size: 12px; color: var(--color-ink-tertiary); align-self: center; } +.chart-board-card-body { display: grid; gap: var(--space-2); min-width: 0; } +.chart-board-tab { gap: 4px; } +.chart-board-tab small { font-size: 11px; color: var(--color-ink-tertiary); font-weight: 400; } + +.chart-tags { display: flex; flex-wrap: wrap; gap: var(--space-2); } +.chart-tag { border-radius: 999px; height: 32px; } +.chart-tag.is-on, +.chart-tag[aria-pressed="true"] { border-color: var(--color-action); color: var(--color-action); background: var(--color-action-soft); } +.chart-tag.is-dasha { color: var(--color-warning); } + +.chart-board-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2); } + +.chart-board-caption { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + background: var(--color-canvas-soft); + min-height: 44px; +} +.chart-board-caption-kicker { font-size: 11px; letter-spacing: .12em; color: var(--color-action); } +.chart-board-caption-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; } +.chart-board-caption-detail { font-size: 12px; color: var(--color-ink-secondary); } + +.chart-board-plot { width: 100%; max-width: 560px; margin-inline: auto; aspect-ratio: 1; } +.chart-board-plot.is-compact { max-width: 100%; } +.chart-board-svg { display: block; width: 100%; height: 100%; max-width: 100%; user-select: none; } + +/* Drawing. Every colour comes from a theme token. */ +.chart-board-cell polygon, +.chart-board-cell rect { fill: var(--color-canvas); stroke: var(--color-border-strong); stroke-width: 1; transition: fill .15s; } +.chart-board-cell { cursor: pointer; outline: none; } +.chart-board-cell:hover polygon, +.chart-board-cell:hover rect { fill: var(--color-canvas-soft); } +.chart-board-cell.is-selected polygon, +.chart-board-cell.is-selected rect { fill: var(--color-action-soft); stroke: var(--color-action); stroke-width: 1.5; } +.chart-board-cell.is-related polygon, +.chart-board-cell.is-related rect { fill: var(--color-canvas-soft); } +.chart-board-center rect { fill: var(--color-canvas-soft); stroke: var(--color-border-strong); } +.chart-board-center circle { fill: none; stroke: var(--color-border); } +.chart-board-asc-mark { stroke: var(--color-action); stroke-width: 1.2; pointer-events: none; } + +.chart-board-ink { transition: opacity .2s; } +.chart-board-ink.is-faded { opacity: .28; } +.chart-board-ink text { pointer-events: none; } +.chart-board-sign { font-size: 10px; fill: var(--color-ink-tertiary); } +.chart-board-sign.is-asc { fill: var(--color-action); font-weight: 600; } +.chart-board-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-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); } +.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; } +.chart-board-body:focus-visible .chart-board-glyph, +.chart-board-cell:focus-visible polygon, +.chart-board-cell:focus-visible rect { stroke: var(--color-focus); stroke-width: 2; } + +.chart-board-link { stroke: var(--color-action); stroke-width: 1.6; fill: none; } +.chart-board-link.is-aspect { stroke-dasharray: 4 4; } +.chart-board-link-dot { fill: var(--color-action); } +.chart-board-links { pointer-events: none; } + +.chart-board-center-text { pointer-events: none; } +.chart-board-center-kicker { font-size: 11px; letter-spacing: .15em; fill: var(--color-action); } +.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 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); } +.chart-board-key.is-bad { background: var(--color-danger-muted); border: 1px solid var(--color-danger); } +.chart-board-key.is-line { width: 16px; height: 0; border-radius: 0; border-top: 2px solid var(--color-action); } +.chart-board-key.is-dash { width: 16px; height: 0; border-radius: 0; border-top: 2px dashed var(--color-action); } +.chart-board-layer-note { font-size: 12px; color: var(--color-ink-tertiary); margin: 0; } + +/* Bands beside numbers (Q5). */ +.chart-band { display: inline-block; margin-left: 4px; padding: 0 5px; border-radius: 5px; font-size: 11px; line-height: 18px; } +.chart-band.is-strong { background: var(--color-success-muted); color: var(--color-success); } +.chart-band.is-medium { background: var(--color-canvas-muted); color: var(--color-ink-secondary); } +.chart-band.is-weak { background: var(--color-danger-muted); color: var(--color-danger); } + +/* 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-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); } +.chart-inspector-head h3 { margin: 0; font-family: var(--font-display); font-size: 18px; font-weight: 600; } +.chart-inspector-head p { margin: 2px 0 4px; font-size: 13px; color: var(--color-ink-secondary); } +.chart-inspector-glyph { + flex: none; width: 44px; height: 44px; border-radius: 12px; + display: grid; place-items: center; + background: var(--color-action-soft); color: var(--color-action); + font-family: var(--font-display); font-size: 22px; font-weight: 700; +} +.chart-inspector-glyph.is-sun { color: var(--color-planet-sun); } +.chart-inspector-glyph.is-moon { color: var(--color-planet-moon); } +.chart-inspector-glyph.is-mars { color: var(--color-planet-mars); } +.chart-inspector-glyph.is-mercury { color: var(--color-planet-mercury); } +.chart-inspector-glyph.is-jupiter { color: var(--color-planet-jupiter); } +.chart-inspector-glyph.is-venus { color: var(--color-planet-venus); } +.chart-inspector-glyph.is-saturn { color: var(--color-planet-saturn); } +.chart-inspector-glyph.is-node { color: var(--color-planet-node); } +.chart-inspector-section { border-top: 1px solid var(--color-border); padding: var(--space-3) 0; } +.chart-inspector-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-2); font-size: 12px; color: var(--color-ink-tertiary); } +.chart-inspector-row strong { color: var(--color-ink); font-size: 13px; font-weight: 600; text-align: right; } +.chart-inspector-band { margin: 4px 0; } +.chart-inspector-band .chart-band { margin-left: 0; } +.chart-inspector-text { margin: 4px 0 0; font-size: 13px; } +.chart-inspector-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); padding-top: var(--space-3); border-top: 1px solid var(--color-border); } +.chart-chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } +.chart-chip { border-radius: 999px; font-weight: 400; } + +.chart-position { position: relative; height: 22px; margin-top: 6px; } +.chart-position-track { position: absolute; inset: 9px 0 auto; height: 4px; border-radius: 2px; background: var(--color-canvas-muted); } +.chart-position-tick { position: absolute; top: 5px; width: 1px; height: 12px; background: var(--color-border-strong); } +.chart-position-mark { position: absolute; top: 1px; width: 2px; height: 20px; margin-left: -1px; background: var(--color-action); } +.chart-meter { position: relative; display: block; height: 8px; border-radius: 4px; background: var(--color-canvas-muted); margin-top: 6px; } +.chart-meter > i { position: absolute; inset: 0 auto 0 0; border-radius: 4px; background: var(--color-action); } +.chart-meter > u { position: absolute; top: -4px; width: 2px; height: 16px; background: var(--color-ink); } +.chart-meter-scale { display: flex; justify-content: space-between; font-size: 11px; color: var(--color-ink-tertiary); margin-top: 4px; } +.chart-dignity { display: grid; gap: 3px; margin-top: 6px; } +.chart-dignity i { height: 5px; border-radius: 2px; background: var(--color-canvas-muted); } +.chart-dignity i.is-on { background: var(--color-ink); } + +/* 九曜 and other tables. */ +.chart-graha-table-wrap { overflow-x: auto; } +.chart-graha-table { width: 100%; font-size: 13px; border-collapse: collapse; } +.chart-graha-table th, +.chart-graha-table td { padding: 7px 8px; border-bottom: 1px solid var(--color-border); text-align: left; white-space: nowrap; font-weight: 400; } +.chart-graha-table thead th { font-size: 12px; color: var(--color-ink-tertiary); } +.chart-graha-table tbody tr { cursor: pointer; } +.chart-graha-table tbody tr:hover > * { background: var(--color-canvas-soft); } +.chart-graha-table tbody tr.is-selected > * { background: var(--color-action-soft); } +.chart-graha-num { font-variant-numeric: tabular-nums; } +.chart-graha-name { gap: 6px; font-weight: 400; min-height: 0; } +.chart-graha-name small { color: var(--color-ink-tertiary); font-size: 11px; } +.chart-graha-glyph { font-family: var(--font-display); font-weight: 600; margin-right: 4px; } +.chart-graha-glyph.is-sun { color: var(--color-planet-sun); } +.chart-graha-glyph.is-moon { color: var(--color-planet-moon); } +.chart-graha-glyph.is-mars { color: var(--color-planet-mars); } +.chart-graha-glyph.is-mercury { color: var(--color-planet-mercury); } +.chart-graha-glyph.is-jupiter { color: var(--color-planet-jupiter); } +.chart-graha-glyph.is-venus { color: var(--color-planet-venus); } +.chart-graha-glyph.is-saturn { color: var(--color-planet-saturn); } +.chart-graha-glyph.is-node { color: var(--color-planet-node); } +.chart-graha-good { color: var(--color-success); } +.chart-graha-bad { color: var(--color-danger); } + +.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); } +.chart-dasha-chip small { font-size: 11px; color: var(--color-ink-tertiary); font-weight: 400; } +.chart-dasha-chip strong { font-family: var(--font-display); font-size: 14px; } +.chart-dasha-chip.is-now { border-color: var(--color-action); } +.chart-dasha-chip.is-now strong { color: var(--color-action); } +.chart-dasha-chip.is-open { background: var(--color-action-soft); } +.chart-dasha-antars { margin-top: var(--space-2); } + +.chart-glance { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); } +.chart-glance-item { display: grid; justify-items: start; gap: 0; padding: var(--space-2); border-radius: var(--radius-md); text-align: left; white-space: normal; } +.chart-glance-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; } +.chart-progress { display: block; width: 100%; height: 4px; border-radius: 2px; background: var(--color-canvas-muted); margin-top: 6px; overflow: hidden; } +.chart-progress > i { display: block; height: 100%; background: var(--color-action); border-radius: 2px; } +.chart-progress.is-tall { height: 6px; border-radius: 3px; } + +/* 深读. */ +.chart-deep { scroll-margin-top: var(--space-16); } +.chart-deep-tabs { width: 100%; } +.chart-deep-body { display: grid; gap: var(--space-6); margin-top: var(--space-4); } +.chart-deep-two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-6); } +@media (max-width: 860px) { .chart-deep-two { grid-template-columns: minmax(0, 1fr); } } +.chart-deep-section { display: grid; gap: var(--space-2); min-width: 0; align-content: start; } +.chart-deep-section > header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: var(--space-2); } +.chart-deep-section > header h3 { margin: 0; font-family: var(--font-display); font-size: 14px; font-weight: 600; } +.chart-deep-section > header span { font-size: 12px; color: var(--color-ink-tertiary); } +.chart-deep-foot { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--space-2); font-size: 12px; color: var(--color-ink-secondary); border-top: 1px solid var(--color-border); padding-top: var(--space-2); } +.chart-deep-more { border-top: 1px solid var(--color-border); padding-top: var(--space-2); } +.chart-deep-more-trigger { gap: 6px; } +.chart-deep-more-trigger[data-panel-open] svg { transform: rotate(180deg); } + +.chart-date-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.chart-date-input { width: auto; min-width: 150px; } + +.chart-period { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 4px var(--space-2); margin-bottom: var(--space-3); } +.chart-period-level { font-size: 12px; color: var(--color-ink-tertiary); padding-top: 4px; } +.chart-period-top { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: var(--space-2); } +.chart-period-top strong { font-family: var(--font-display); font-size: 16px; font-weight: 600; } +.chart-period-top span { font-size: 12px; color: var(--color-ink-tertiary); font-variant-numeric: tabular-nums; } +.chart-period small { font-size: 12px; color: var(--color-ink-secondary); } + +.chart-scroll-wrap { overflow-x: auto; } +.chart-scroll-inner { min-width: 620px; display: grid; gap: var(--space-3); } +.chart-scroll-label { display: flex; justify-content: space-between; font-size: 12px; color: var(--color-ink-tertiary); margin-bottom: 4px; } +.chart-scroll-bar { position: relative; display: flex; height: 36px; border-radius: 8px; overflow: hidden; background: var(--color-canvas-soft); } +.chart-scroll-seg { + height: 100%; min-width: 0; padding: 0 2px; border-radius: 0; gap: 3px; + border-right: 1px solid var(--color-canvas); font-family: var(--font-display); font-size: 13px; white-space: nowrap; overflow: hidden; +} +.chart-scroll-seg small { font-family: var(--font-body); font-size: 11px; color: var(--color-ink-tertiary); } +.chart-scroll-seg.is-now { background: var(--color-action-soft); color: var(--color-action); box-shadow: inset 0 -2px 0 var(--color-action); } +.chart-scroll-seg.is-open { background: var(--color-canvas-muted); } +.chart-scroll-seg:disabled { opacity: 1; cursor: default; } +.chart-scroll-now { position: absolute; top: 0; bottom: 0; width: 2px; margin-left: -1px; background: var(--color-action); pointer-events: none; } + +.chart-shadbala { display: grid; gap: 0; } +.chart-shadbala-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 96px; align-items: center; gap: var(--space-2); padding: 6px 4px; border-radius: 0; border-bottom: 1px solid var(--color-border); font-weight: 400; } +.chart-shadbala-row .chart-graha-glyph { font-size: 17px; margin: 0; } +.chart-shadbala-bars { display: grid; gap: 3px; min-width: 0; } +.chart-shadbala-bars .chart-meter { margin-top: 0; height: 7px; } +.chart-shadbala-parts { display: flex; height: 4px; border-radius: 2px; overflow: hidden; } +.chart-shadbala-parts i, +.chart-shadbala-key { display: inline-block; height: 100%; } +.chart-shadbala-key { width: 9px; height: 9px; border-radius: 50%; } +.chart-shadbala-parts .is-sthana, .chart-shadbala-key.is-sthana { background: var(--color-action); } +.chart-shadbala-parts .is-dig, .chart-shadbala-key.is-dig { background: var(--color-success); } +.chart-shadbala-parts .is-kala, .chart-shadbala-key.is-kala { background: var(--color-planet-saturn); } +.chart-shadbala-parts .is-chesta, .chart-shadbala-key.is-chesta { background: var(--color-warning); } +.chart-shadbala-parts .is-naisargika, .chart-shadbala-key.is-naisargika { background: var(--color-ink-tertiary); } +.chart-shadbala-parts .is-drik, .chart-shadbala-key.is-drik { background: var(--color-planet-venus); } +.chart-shadbala-value { text-align: right; font-size: 13px; font-variant-numeric: tabular-nums; } +.chart-shadbala-value small { display: block; font-size: 11px; color: var(--color-ink-tertiary); } + +.chart-bhava-bars { position: relative; display: flex; align-items: flex-end; gap: 6px; height: 180px; padding-top: 18px; border-bottom: 1px solid var(--color-border); } +.chart-bhava-bar { flex: 1; min-width: 0; height: 100%; padding: 0; border-radius: 0; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 2px; } +.chart-bhava-bar small { font-size: 10px; color: var(--color-ink-secondary); font-weight: 400; } +.chart-bhava-bar i { display: block; width: 100%; border-radius: 5px 5px 0 0; background: var(--color-canvas-muted); } +.chart-bhava-bar.is-strong i { background: color-mix(in srgb, var(--color-action) 45%, var(--color-canvas)); } +.chart-bhava-bar.is-weak i { background: color-mix(in srgb, var(--color-planet-saturn) 30%, var(--color-canvas)); } +.chart-bhava-bar.is-on i { outline: 2px solid var(--color-action); } +.chart-bhava-mean { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--color-ink-tertiary); pointer-events: none; } +.chart-bhava-mean em { position: absolute; left: 0; top: -15px; font-size: 10px; font-style: normal; color: var(--color-ink-tertiary); } +.chart-bhava-axis { display: flex; gap: 6px; } +.chart-bhava-axis span { flex: 1; text-align: center; font-size: 11px; color: var(--color-ink-tertiary); } + +.chart-av-wrap { overflow-x: auto; } +.chart-av { border-collapse: separate; border-spacing: 3px; width: auto; font-size: 12px; } +.chart-av th { font-weight: 400; color: var(--color-ink-tertiary); text-align: center; line-height: 1.25; padding: 2px; } +.chart-av tbody th { font-family: var(--font-display); font-size: 14px; color: var(--color-ink); text-align: left; padding-right: 6px; } +.chart-av td { padding: 0; text-align: center; } +.chart-av td span { display: inline-grid; place-items: center; width: 28px; height: 26px; border-radius: 6px; background: var(--color-canvas-soft); font-variant-numeric: tabular-nums; } +.chart-av td span.is-good { background: var(--color-action-soft); } +.chart-av td span.is-high { background: color-mix(in srgb, var(--color-action) 28%, var(--color-canvas)); } +.chart-av td span.is-low { color: var(--color-planet-saturn); } +.chart-av-total td span { font-weight: 700; } +.chart-av-sum { font-size: 12px; color: var(--color-ink-secondary); padding-left: 4px; } + +.chart-yoga-list { display: grid; gap: var(--space-2); } +.chart-yoga { justify-content: space-between; gap: var(--space-3); padding: 10px 12px; border-radius: 12px; text-align: left; white-space: normal; } +.chart-yoga.is-on { border-color: var(--color-action); background: var(--color-action-soft); } +.chart-yoga-text { display: grid; gap: 2px; } +.chart-yoga-text strong { font-family: var(--font-display); font-weight: 600; font-size: 14px; } +.chart-yoga-text small { font-size: 12px; color: var(--color-ink-secondary); font-weight: 400; } +.chart-yoga-unmet { margin: var(--space-2) 0 0; padding-left: 1.2em; font-size: 13px; color: var(--color-ink-secondary); display: grid; gap: 4px; } + +.chart-book-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); margin: 0; } +.chart-book-grid dt { font-size: 12px; color: var(--color-ink-tertiary); } +.chart-book-grid dd { margin: 2px 0 0; font-size: 14px; } +@media (max-width: 640px) { .chart-book-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +/* Chat side panel (issue #114 M8). */ +.chat-chart-workspace { display: grid; gap: var(--space-3); padding: var(--space-3); } +.chat-chart-workspace .chart-board-card { box-shadow: none; } +.chat-chart-head { display: flex; align-items: center; gap: var(--space-2); } +.chat-chart-head strong { font-family: var(--font-display); font-size: 15px; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chat-chart-sheet-body { overflow-y: auto; max-height: 80dvh; } + +@media (prefers-reduced-motion: reduce) { + .chart-board-cell polygon, + .chart-board-cell rect, + .chart-board-ink { transition: none; } +} +.chart-inspector { min-width: 0; } +.chart-scroll-row { min-width: 0; } +.chat-chart-sheet { --drawer-height: 86dvh; } diff --git a/frontend/src/components/chart-board/chart-deep-reading.tsx b/frontend/src/components/chart-board/chart-deep-reading.tsx new file mode 100644 index 00000000..bdecfc68 --- /dev/null +++ b/frontend/src/components/chart-board/chart-deep-reading.tsx @@ -0,0 +1,692 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { ChevronDown } 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 { 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 { + BAND_ZH, + bhavaBand, + bhavaMean, + natalBoard, + progressOf, + savBand, + SAV_REFERENCE, + signLabelAt, + vimshottariAt, +} from "@/lib/chart-board-view"; +import type { ChartViewFailureStatus, ChartViewOk, ChartViewTransitOk } from "@/lib/chart-view-contract"; +import type { ChartViewLayer } from "@/lib/chart-view-engine"; +import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; +import { STRENGTH_LEVEL_ZH } from "@/lib/chart-view-insight"; +import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels"; +import { vimshottariSplit, type DashaSpan } from "@/lib/dasha-span"; +import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey"; +import { PLANET_COLOR_CLASS, PLANET_GLYPH_ZH, PLANET_NAME_ZH, planetGlyphKey, type PlanetGlyphKey } from "@/lib/planet-glyphs"; + +export type ChartDeepTab = "time" | "energy" | "yoga" | "book"; + +const SHADBALA_PARTS = [ + ["sthana", "位置力"], + ["dig", "方位力"], + ["kala", "时间力"], + ["chesta", "运动力"], + ["naisargika", "自然力"], + ["drik", "视相力"], +] as const; + +function dayShift(iso: string, unit: "month" | "year", amount: number): string { + const [year, month, day] = iso.split("-").map(Number); + const target = unit === "year" + ? new Date(Date.UTC(year! + amount, month! - 1, 1)) + : new Date(Date.UTC(year!, month! - 1 + amount, 1)); + const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate(); + target.setUTCDate(Math.min(day!, lastDay)); + return target.toISOString().slice(0, 10); +} + +function restText(end: string, day: string): string { + const days = Math.max(0, Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) / 86_400_000)); + return days >= 365 ? `${(days / 365.25).toFixed(1)} 年` : `${days} 天`; +} + +function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) { + return ( +
+
+

{title}

+ {hint ? {hint} : null} +
+ {children} +
+ ); +} + +function PeriodRow({ level, span, day }: { level: string; span: DashaSpan | null; day: string }) { + if (!span) return null; + const key = planetGlyphKey(span.lord); + const progress = progressOf(span.start, span.end, day); + return ( +
+ {level} +
+
+ + {key ? {PLANET_GLYPH_ZH[key]} : null} + {span.label} + + {span.start} – {span.end} +
+ + 已过 {Math.round(progress * 100)}% · 剩余 {restText(span.end, day)} +
+
+ ); +} + +function ScrollRow({ label, range, spans, day, open, onOpen }: { + label: string; + range: string; + spans: readonly DashaSpan[]; + day: string; + open: string | null; + onOpen?: (span: DashaSpan) => void; +}) { + const first = spans[0]; + const last = spans.at(-1); + if (!first || !last) return null; + const a = Date.parse(`${first.start}T00:00:00Z`); + const b = Date.parse(`${last.end}T00:00:00Z`); + const t = Date.parse(`${day}T00:00:00Z`); + const width = b - a; + return ( +
+
{label}{range}
+
+ {spans.map((span) => { + const share = ((Date.parse(`${span.end}T00:00:00Z`) - Date.parse(`${span.start}T00:00:00Z`)) / width) * 100; + const isNow = span.start <= day && day < span.end; + const key = planetGlyphKey(span.lord); + return ( + + ); + })} + {t >= a && t < b ? : null} +
+
+ ); +} + +function TimeTab({ view, observe, onObserve, transit, transitPending, transitFailed, board, pendingLayers, layerFailures, onNeedLayer }: { + view: ChartViewOk; + observe: string; + onObserve: (day: string) => void; + transit: ChartViewTransitOk | null; + transitPending: boolean; + transitFailed: boolean; + board: ChartBoardState; + pendingLayers?: ReadonlySet; + layerFailures?: ReadonlyMap; + onNeedLayer?: (layer: ChartViewLayer) => void; +}) { + const now = vimshottariAt(view, observe); + const pratyantars = now?.antar ? vimshottariSplit(now.antar.lord, now.antar.start, now.antar.end) : []; + const pratyantar = pratyantars.find((span) => span.start <= observe && observe < span.end) ?? null; + const [openMaha, setOpenMaha] = useState(null); + const [openAntar, setOpenAntar] = useState(null); + const [system, setSystem] = useState<"chara" | "yogini">("chara"); + const periods = view.dasha.vimshottari.periods; + const maha = periods.find((period) => period.start === openMaha) ?? now?.maha ?? periods[0]; + const antars: DashaSpan[] = maha ? maha.antardashas.map((item) => ({ lord: item.lord, label: item.label, start: item.start, end: item.end })) : []; + const antar = antars.find((item) => item.start === openAntar) + ?? antars.find((item) => item.start <= observe && observe < item.end) + ?? antars[0]; + const pratyantarRow = antar ? vimshottariSplit(antar.lord, antar.start, antar.end) : []; + const moonSign = SIGN_ORDER.indexOf((view.vedic.planets.find((row) => row.name === "Moon")?.sign ?? "") as (typeof SIGN_ORDER)[number]); + const sav = view.ashtakavarga?.status === "ok" ? view.ashtakavarga.sav : null; + const saturn = transit?.rows.find((row) => row.name === "Saturn"); + const saturnFromMoon = saturn && moonSign >= 0 ? ((SIGN_ORDER.indexOf(saturn.sign as (typeof SIGN_ORDER)[number]) - moonSign + 12) % 12) + 1 : null; + const sadeSati = saturnFromMoon === null ? null + : saturnFromMoon === 12 ? "土星七年半 · 第一段" + : saturnFromMoon === 1 ? "土星七年半 · 第二段" + : saturnFromMoon === 2 ? "土星七年半 · 第三段" + : "土星七年半 · 未激活"; + const today = view.dasha.asOf; + // Shown tab → its layers: SAV for the transit table, Chara for the dual track. + useEffect(() => { + onNeedLayer?.("ashtakavarga"); + onNeedLayer?.("chara"); + onNeedLayer?.("transit"); + }, [onNeedLayer]); + return ( +
+
+
+ { + if (/^\d{4}-\d{2}-\d{2}$/.test(event.target.value)) onObserve(event.target.value); + }} + /> + + + + + +
+
+
+
+ {now ? ( + <> + + + + + ) :

这一天不在已算出的大运范围内。

} +
+
+ {transit ? ( +
+ + + + + + + + + + + + {transit.rows.map((row) => { + const key = planetGlyphKey(row.name); + const sign = SIGN_ORDER.indexOf(row.sign as (typeof SIGN_ORDER)[number]); + const points = sav && sign >= 0 ? sav[sign] : null; + return ( + + + + + + + + ); + })} + +
星体星座上升起月亮起八宫点
+ {key ? {PLANET_GLYPH_ZH[key]} : null} + {row.label}{row.retrograde ? 逆 : null} + {row.signLabel} {formatDegreeMinutes(row.degreeInSign)}{row.natalHouse}{moonSign >= 0 && sign >= 0 ? ((sign - moonSign + 12) % 12) + 1 : "—"} + {points ?? "—"} + {points !== null && points !== undefined ? {BAND_ZH[savBand(points)]} : null} +
+
+ ) : transitFailed ? ( +

这一天的行运暂时没算出来,稍后再试。

+ ) : ( +

+ )} +
+ 行运是这一天正午的位置,不代表事件一定发生。 + +
+
+
+
+
+
+ ({ lord: item.lord, label: item.label, start: item.start, end: item.end }))} + day={observe} + open={maha?.start ?? null} + onOpen={(span) => { setOpenMaha(span.start); setOpenAntar(null); }} + /> + {maha ? ( + setOpenAntar(span.start)} + /> + ) : null} + {antar ? ( + + ) : null} +
+
+
+
+ { + setSystem(next); + onNeedLayer?.(next); + }} + /> +
+
+ ); +} + +function EnergyTab({ view, board, pending, failure, onNeedLayer }: { + onNeedLayer?: (layer: ChartViewLayer) => void; + view: ChartViewOk; + board: ChartBoardState; + pending: boolean; + failure?: ChartViewFailureStatus; +}) { + const [by, setBy] = useState<"house" | "sign">("house"); + const [reading, setReading] = useState<"raw" | "trikona" | "sodhita">("raw"); + const rows = [...(view.shadbala ?? [])].sort((a, b) => b.percent - a.percent); + const max = Math.max(10, ...rows.map((row) => row.rupas)); + const av = view.ashtakavarga?.status === "ok" ? view.ashtakavarga : null; + const mean = bhavaMean(view); + const natal = natalBoard(view); + const columns = by === "house" ? natal.houses.map((house) => house.sign) : SIGN_ORDER.map((_, index) => index); + const bhavaMax = av?.bhavaBala ? Math.max(...av.bhavaBala.map((house) => house.total), mean ?? 0) * 1.1 : 1; + const selectedHouse = board.selection?.kind === "house" ? board.selection.house : null; + useEffect(() => { + onNeedLayer?.("ashtakavarga"); + }, [onNeedLayer]); + return ( +
+
+
+ {rows.length > 0 ? ( +
+ {rows.map((row) => { + const key = planetGlyphKey(row.name); + const parts = row.parts; + const partsTotal = parts ? SHADBALA_PARTS.reduce((sum, [id]) => sum + Math.max(0, parts[id]), 0) : 0; + return ( + + ); + })} +
+ {SHADBALA_PARTS.map(([id, label]) => {label})} +
+

强 = 达标 120% 以上,中 = 90%~120%,弱 = 不到 90%。

+
+ ) :

这张盘没有返回六力。

} +
+
+ {av?.bhavaBala && mean !== null ? ( + <> +
+ 均值 {Math.round(mean)} + {av.bhavaBala.map((house) => { + const band = bhavaBand(house.total, mean); + return ( + + ); + })} +
+
{av.bhavaBala.map((house) => {house.house})}
+
+ 单位 {av.bhavaBalaUnit};高于均值一成记强,低一成记弱。 + +
+ + ) : !pending && failure ?

{chartLayerFailureCopy(failure)}

+ :

} +
+
+
+ {av ? ( + <> +
+ value && setBy(value as "house" | "sign")}> + + 按宫位 + 按星座 + + + value && setReading(value as "raw" | "trikona" | "sodhita")}> + + 原始 + 三角削减 + 两步削减 + + +
+
+ + + + + {columns.map((sign, index) => ( + + ))} + + + + + {av.bav.map((row) => { + const values = row[reading]; + const key = planetGlyphKey(row.name); + return ( + + + {columns.map((sign) => { + const value = values[sign] ?? 0; + return ; + })} + + + ); + })} + + + {columns.map((sign) => { + const total = av.bav.reduce((sum, row) => sum + (row[reading][sign] ?? 0), 0); + return ; + })} + + + +
星体{by === "house" ? <>{index + 1}宫
: null}{signZh(SIGN_ORDER[sign] ?? "")}
计
{key ? PLANET_GLYPH_ZH[key] : row.label}= 6 ? "is-high" : value >= 5 ? "is-good" : value <= 2 ? "is-low" : undefined}>{value}{values.reduce((sum, value) => sum + value, 0)}
总= SAV_REFERENCE ? "is-good" : "is-low") : undefined}>{total}{av.bav.reduce((sum, row) => sum + row[reading].reduce((a, b) => a + b, 0), 0)}
+
+
+ {SAV_REFERENCE} 是传统参考线,不是本盘均值。 + +
+ + ) : !pending && failure ?

{chartLayerFailureCopy(failure)}

+ :

} +
+
+ ); +} + +function YogaTab({ view, board }: { view: ChartViewOk; board: ChartBoardState }) { + const yogas = view.yogas; + const focused = board.yogaBodies?.join(",") ?? null; + const pick = (planets: readonly string[]) => { + const keys = planets.map((name) => planetGlyphKey(name)).filter((key): key is PlanetGlyphKey => key !== null); + board.focusYoga(focused === keys.join(",") ? null : keys); + document.getElementById("chart-board-anchor")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + if (!yogas) return

这张盘没有返回格局。

; + return ( +
+
+ {yogas.formed.length > 0 ? ( +
+ {yogas.formed.map((yoga) => { + const keys = yoga.planets.map((name) => planetGlyphKey(name)).filter((key): key is PlanetGlyphKey => key !== null); + const on = focused !== null && focused === keys.join(","); + return ( + + ); + })} +
+ ) :

本盘没有成立的经典格局。

} + {yogas.notFormed.length > 0 ? ( + + }> + 未满足 {yogas.notFormed.length} 条 + + +
    + {yogas.notFormed.map((yoga) =>
  • {yoga.label}
  • )} +
+
+
+ ) : null} +

传统格局,本站未验证,只作参考。

+
+
+ ); +} + +function BookTab({ view }: { view: ChartViewOk }) { + const natal = natalBoard(view); + const calendar = view.birthCalendar; + const moon = view.vedic.planets.find((row) => row.name === "Moon"); + return ( +
+
+ +

{view.vedic.boundary}

+
+ {calendar ? ( +
+
+
提毗
{calendar.tithi ?? "—"}
+
月宿
{moon ? `${moon.nakshatra} · 第 ${moon.pada} 足` : "—"}
+
星期
{calendar.weekday}
+
+
+ ) : null} +
+
+
+ + + + + + + + + + + + {view.vedic.planets.map((row) => { + const key = planetGlyphKey(row.name); + const dignity = dignityStep(row.status); + return ( + + + + + + + + ); + })} + +
星体位置月宿状态D9
+ {key ? {PLANET_GLYPH_ZH[key]} : null} + {row.label} + {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} · {row.house}宫{row.nakshatra}{dignity?.label ?? "—"}{row.retrograde ? " · 逆" : ""}{row.d9SignLabel ?? "—"}
+
+
+
+
+ + + + + + + + + + {natal.houses.map((house) => { + const lord = houseLord(natal, house.house); + const at = lord ? bodyHouse(natal, lord) : null; + return ( + + + + + + ); + })} + +
宫位宫主落宫
{house.house}宫 · {signLabelAt(house.sign)}{lord ? PLANET_NAME_ZH[lord] : "—"}{at ? `${at}宫 · ${signLabelAt(natal.houses[at - 1]!.sign)}` : "—"}
+
+
+
+
+ ); +} + +const DEEP_TABS: ReadonlyArray<{ id: ChartDeepTab; label: string; hintTab?: "dasha" | "basics" }> = [ + { id: "time", label: "时运流年", hintTab: "dasha" }, + { id: "energy", label: "能量图谱" }, + { id: "yoga", label: "命性格局" }, + { id: "book", label: "排盘底册", hintTab: "basics" }, +]; + +export function ChartDeepReading({ + view, + tab, + onTab, + pendingLayers, + layerFailures, + onNeedLayer, + board, + observe, + onObserve, + transit, +}: { + view: ChartViewOk; + tab: ChartDeepTab; + onTab: (tab: ChartDeepTab) => void; + pendingLayers?: ReadonlySet; + layerFailures?: ReadonlyMap; + onNeedLayer?: (layer: ChartViewLayer) => void; + board: ChartBoardState; + observe: string; + onObserve: (day: string) => void; + transit: { transit: ChartViewTransitOk | null; pending: boolean; failed: boolean }; +}) { + const journey = useOnboardingJourneyOptional(); + const now = vimshottariAt(view, view.dasha.asOf); + const formed = view.yogas?.formed.length ?? 0; + function select(next: ChartDeepTab) { + onTab(next); + } + return ( + + + 深读 + + + value && select(value as ChartDeepTab)}> + + {DEEP_TABS.map((item) => ( + + {item.label} + {item.id === "time" && now ? {PLANET_GLYPH_ZH[planetGlyphKey(now.maha.lord) ?? "sun"]}{now.antar ? ` · ${PLANET_GLYPH_ZH[planetGlyphKey(now.antar.lord) ?? "sun"]}` : ""} : null} + {item.id === "yoga" && formed > 0 ? {formed} 组 : null} + {item.hintTab && journey?.tabHasDot(item.hintTab) ? : null} + + ))} + + + {tab === "time" ? ( + + ) : null} + {tab === "energy" ? ( + + ) : null} + {tab === "yoga" ? : null} + {tab === "book" ? : null} + + + ); +} diff --git a/frontend/src/components/chart-board/chart-inspector.tsx b/frontend/src/components/chart-board/chart-inspector.tsx new file mode 100644 index 00000000..e9e090cb --- /dev/null +++ b/frontend/src/components/chart-board/chart-inspector.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { ChevronLeft, ChevronRight, MessageCircleQuestion, X } from "lucide-react"; +import type { ReactNode } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { formatDegreeMinutes } from "@/components/chart-page/chart-format"; +import { + aspectedHouses, + bodiesAspecting, + bodyHouse, + dignityStep, + DIGNITY_STEP_COUNT, + houseLord, + lordedHouses, + type BoardModel, + type BoardSelection, +} from "@/lib/chart-board-model"; +import { + BAND_ZH, + bhavaBand, + bhavaByHouse, + bhavaMean, + HOUSE_THEME_ZH, + nakshatraTicks, + planetRowFor, + savBand, + savByHouse, + shadbalaFor, + signLabelAt, + transitBySign, + type BoardSource, +} from "@/lib/chart-board-view"; +import type { ChartViewOk, ChartViewTransitOk } from "@/lib/chart-view-contract"; +import { STRENGTH_LEVEL_ZH } from "@/lib/chart-view-insight"; +import { PLANET_COLOR_CLASS, PLANET_GLYPH_ZH, PLANET_NAME_ZH, planetGlyphKey, type PlanetGlyphKey } from "@/lib/planet-glyphs"; + +/** The question 「问 AI」 puts in the composer; it is never sent by itself (issue #114 Q7). */ +export function inspectorQuestion(view: ChartViewOk, model: BoardModel, selection: NonNullable): string { + if (selection.kind === "body") { + const house = bodyHouse(model, selection.key); + const sign = house ? signLabelAt(model.houses[house - 1]!.sign) : ""; + return `我的${PLANET_NAME_ZH[selection.key]}在${sign}第${house ?? "—"}宫,对我意味着什么?`; + } + return `我的第${selection.house}宫(${HOUSE_THEME_ZH[selection.house - 1]})怎么样?`; +} + +function Section({ label, value, children }: { label: string; value?: string; children?: ReactNode }) { + return ( +
+
+ {label} + {value ? {value} : null} +
+ {children} +
+ ); +} + +function Band({ band }: { band: "strong" | "medium" | "weak" }) { + return {BAND_ZH[band]}; +} + +function Meter({ value, max, mark, markLabel }: { value: number; max: number; mark?: number; markLabel?: string }) { + return ( + <> +
+ + {mark !== undefined ? : null} +
+ {markLabel ?
0{markLabel}
: null} + + ); +} + +function BodyDetail({ view, model, source, keyName, transitSky }: { + view: ChartViewOk; + transitSky: ChartViewTransitOk | null; + model: BoardModel; + source: BoardSource; + keyName: PlanetGlyphKey; +}) { + const house = bodyHouse(model, keyName); + const body = house ? model.houses[house - 1]!.bodies.find((item) => item.key === keyName) : undefined; + const row = planetRowFor(view, keyName); + const dignity = row && source.natal ? dignityStep(row.status) : null; + const strength = shadbalaFor(view, keyName); + const lorded = lordedHouses(model, keyName); + const aspects = aspectedHouses(model, keyName); + const periods = view.dasha.vimshottari.periods.filter((period) => planetGlyphKey(period.lord) === keyName); + const transit = transitBySign(transitSky).find((item) => item.key === keyName); + const natal = model.houses.find((item) => item.sign === transit?.sign); + const sav = view.ashtakavarga?.status === "ok" && transit ? view.ashtakavarga.sav[transit.sign] : null; + return ( + <> + {body ? ( +
+
+
+ {source.natal ? nakshatraTicks(body.sign).map((tick) => ( + + )) : null} + +
+
0°{formatDegreeMinutes(body.degree)}30°
+
+ ) : null} + {row ? ( +
+ ) : null} + {dignity ? ( +
+
+ {Array.from({ length: DIGNITY_STEP_COUNT }, (_, index) => ( + + ))} +
+
落陷中性入旺
+
+ ) : null} + {strength ? ( +
+
+ +
+ ) : keyName === "rahu" || keyName === "ketu" ?
: null} +
+
+ {aspects.map((target) => ( + 第{target}宫 · {signLabelAt(model.houses[target - 1]?.sign ?? -1)} + ))} +
+
+
0 ? lorded.map((item) => `第${item}宫`).join("、") : "交点不管宫"} /> + {periods.length > 0 ? ( +
+
+ {periods.map((period) => ( + + {period.start.slice(0, 4)}–{period.end.slice(0, 4)} + + ))} +
+
+ ) : null} + {transit && transitSky ? ( +
+

+ 在{signLabelAt(transit.sign)} {formatDegreeMinutes(transit.degree)}{transit.retrograde ? "(逆行)" : ""} + {natal ? `,本命第 ${natal.house} 宫` : ""} + {sav !== null && sav !== undefined ? `,该座八宫点 ${sav}` : ""} +

+
+ ) : null} + + ); +} + +function HouseDetail({ view, model, source, house }: { + view: ChartViewOk; + model: BoardModel; + source: BoardSource; + house: number; +}) { + const lord = houseLord(model, house); + const lordAt = lord ? bodyHouse(model, lord) : null; + const occupants = model.houses[house - 1]?.bodies ?? []; + const aspecting = bodiesAspecting(model, house); + const sav = source.natal ? savByHouse(view, model)[house - 1] : null; + const bhava = source.natal ? bhavaByHouse(view)[house - 1] : null; + const mean = bhavaMean(view); + return ( + <> +
+
0 ? occupants.map((body) => PLANET_NAME_ZH[body.key]).join("、") : "无"} /> +
0 ? aspecting.map((key) => PLANET_NAME_ZH[key]).join("、") : "无"} /> + {bhava !== null && bhava !== undefined && mean !== null ? ( +
+
+ +
+ ) : null} + {sav !== null && sav !== undefined ? ( +
+
+
+ ) : null} + + ); +} + +export function ChartInspector({ + view, + model, + source, + selection, + onSelect, + onStep, + onAsk, + onLocateD9, + transit, +}: { + view: ChartViewOk; + /** The observation day's sky; null until loaded. */ + transit: ChartViewTransitOk | null; + model: BoardModel | null; + source: BoardSource | null; + selection: BoardSelection; + onSelect: (selection: BoardSelection) => void; + onStep: (direction: 1 | -1) => void; + onAsk: (question: string) => void; + /** Absent where the D9 is already shown. */ + onLocateD9?: () => void; +}) { + if (!selection || !model || !source) { + return ( + + + 盘面检视 + + +

点盘上的星或宫看详情

+

方向键切换 · Esc 收起

+
+
+ ); + } + const isBody = selection.kind === "body"; + const house = isBody ? bodyHouse(model, selection.key) : selection.house; + const sign = house ? model.houses[house - 1]?.sign ?? -1 : -1; + const body = isBody && house ? model.houses[house - 1]?.bodies.find((item) => item.key === selection.key) : undefined; + const row = isBody ? planetRowFor(view, selection.key) : null; + const strength = isBody ? shadbalaFor(view, selection.key) : null; + return ( + + + 盘面检视 + + + + + + + +
+ + {isBody ? PLANET_GLYPH_ZH[selection.key] : house} + +
+

{isBody ? PLANET_NAME_ZH[selection.key] : `第 ${house} 宫 · ${signLabelAt(sign)}`}

+

+ {isBody + ? `${signLabelAt(sign)} ${body ? formatDegreeMinutes(body.degree) : ""} · 第${house ?? "—"}宫` + : HOUSE_THEME_ZH[(house ?? 1) - 1]} +

+ {isBody ? ( +
+ {row && source.natal && dignityStep(row.status) ? ( + {dignityStep(row.status)!.label} + ) : null} + {body?.retrograde ? 逆行 : null} + {strength ? 六力{STRENGTH_LEVEL_ZH[strength.level]} : null} +
+ ) : null} +
+
+ {isBody + ? + : } +
+ + {isBody && onLocateD9 ? ( + + ) : null} +
+
+
+ ); +} diff --git a/frontend/src/components/chart-board/chart-side-cards.tsx b/frontend/src/components/chart-board/chart-side-cards.tsx new file mode 100644 index 00000000..e5bb9dbf --- /dev/null +++ b/frontend/src/components/chart-board/chart-side-cards.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { useState, type ReactNode } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table } from "@/components/ui/table"; +import { formatDegreeMinutes } from "@/components/chart-page/chart-format"; +import { bodyHouse, dignityStep, type BoardModel, type BoardSelection } from "@/lib/chart-board-model"; +import { + glanceOf, + HOUSE_THEME_ZH, + progressOf, + shadbalaFor, + vimshottariAt, +} 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"; + +function selectedBody(selection: BoardSelection) { + return selection?.kind === "body" ? selection.key : null; +} + +/** 九曜: the natal bodies; a row selects the body on the board. */ +export function ChartGrahaCard({ view, selection, onSelect, compact = false }: { + view: ChartViewOk; + selection: BoardSelection; + onSelect: (selection: BoardSelection) => void; + compact?: boolean; +}) { + const current = selectedBody(selection); + return ( + + + 九曜 + 恒星黄道 · 本命 + + +
+ + + + + + + + {compact ? null : } + + + + + {view.vedic.planets.map((row) => { + const key = planetGlyphKey(row.name); + if (!key) return null; + const strength = shadbalaFor(view, key); + const dignity = dignityStep(row.status); + const on = current === key; + return ( + onSelect(on ? null : { kind: "body", key })} + > + + + + + {compact ? null : ( + + )} + + + ); + })} + +
星体星座 · 度宫月宿 · 足六力尊贵
+ + {row.signLabel} {formatDegreeMinutes(row.degreeInSign)}{row.house}{row.nakshatra} · {row.pada} + {strength ? <>{Math.round(strength.percent)}%{STRENGTH_LEVEL_ZH[strength.level]} : "—"} + = 6 ? "chart-graha-good" : undefined}> + {dignity?.label ?? "—"} +
+
+
+
+ ); +} + +export function ChartHouseThemesCard({ model, selection, onSelect }: { + model: BoardModel; + selection: BoardSelection; + onSelect: (selection: BoardSelection) => void; +}) { + return ( + + + 宫位主题 + + + {HOUSE_THEME_ZH.map((theme, index) => { + const house = index + 1; + const on = selection?.kind === "house" && selection.house === house; + const occupied = (model.houses[index]?.bodies.length ?? 0) > 0; + return ( + + ); + })} + + + ); +} + +/** Vimshottari mahadashas; one opens to its antardashas. */ +export function ChartDashaStripCard({ view }: { view: ChartViewOk }) { + const today = view.dasha.asOf; + const now = vimshottariAt(view, today); + const [open, setOpen] = useState(null); + const periods = view.dasha.vimshottari.periods; + const shown = periods.find((period) => period.start === open) ?? now?.maha ?? periods[0]; + return ( + + + Vimshottari 大运 + + +
+ {periods.map((period) => { + const isNow = now?.maha.start === period.start; + const isOpen = shown?.start === period.start; + return ( + + ); + })} +
+ {shown ? ( +
+ {shown.antardashas.map((antar) => ( + + {antar.label} {antar.start.slice(0, 7)} + + ))} +
+ ) : null} +
+
+ ); +} + +export function ChartGlanceCard({ view, model, onSelect, onOpenTime }: { + view: ChartViewOk; + model: BoardModel; + onSelect: (selection: BoardSelection) => void; + onOpenTime: () => void; +}) { + const glance = glanceOf(view, view.dasha.asOf); + const antar = glance.dasha?.antar; + const progress = antar ? progressOf(antar.start, antar.end, view.dasha.asOf) : 0; + const strongestKey = glance.strongest ? planetGlyphKey(glance.strongest.name) : null; + const weakestKey = glance.weakest ? planetGlyphKey(glance.weakest.name) : null; + const weakestRow = weakestKey ? view.vedic.planets.find((row) => planetGlyphKey(row.name) === weakestKey) : null; + const weakDignity = weakestRow ? dignityStep(weakestRow.status) : null; + const item = (key: string, label: string, value: string, detail: string, onClick: () => void, extra?: ReactNode) => ( + + ); + return ( + + + 命盘速览 + + + {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.dasha ? item( + "dasha", + "当前运期", + `${glance.dasha.maha.label}${antar ? ` › ${antar.label}` : ""}`, + antar ? `分运到 ${antar.end}` : `大运到 ${glance.dasha.maha.end}`, + onOpenTime, + , + ) : null} + {glance.strongest && strongestKey ? item("strong", "六力最强", glance.strongest.label, `达标 ${Math.round(glance.strongest.percent)}% · ${STRENGTH_LEVEL_ZH[glance.strongest.level]}`, () => onSelect({ kind: "body", key: strongestKey })) : null} + {glance.weakest && weakestKey ? item("weak", "需留意", glance.weakest.label, `达标 ${Math.round(glance.weakest.percent)}% · ${STRENGTH_LEVEL_ZH[glance.weakest.level]}${weakDignity?.step === 0 ? " · 落陷" : ""}`, () => onSelect({ kind: "body", key: weakestKey })) : null} + + + ); +} + +/** Page-head tags: 上升 / 月亮 / 太阳 / 当前大运; each locates on the board. */ +export function ChartTags({ view, model, selection, onSelect }: { + view: ChartViewOk; + model: BoardModel; + selection: BoardSelection; + onSelect: (selection: BoardSelection) => void; +}) { + const sun = view.vedic.planets.find((row) => row.name === "Sun"); + const moon = view.vedic.planets.find((row) => row.name === "Moon"); + const now = vimshottariAt(view, view.dasha.asOf); + const nowKey = now ? planetGlyphKey(now.maha.lord) : null; + const tags: Array<{ id: string; label: string; target: NonNullable }> = []; + if (view.vedic.ascendant) tags.push({ id: "asc", label: `上升 ${view.vedic.ascendant.signLabel}`, target: { kind: "house", house: model.ascendantHouse } }); + if (moon) tags.push({ id: "moon", label: `月亮 ${moon.signLabel}`, target: { kind: "body", key: "moon" } }); + if (sun) tags.push({ id: "sun", label: `太阳 ${sun.signLabel}`, target: { kind: "body", key: "sun" } }); + if (now && nowKey && bodyHouse(model, nowKey) !== null) tags.push({ id: "dasha", label: `当前大运 ${now.maha.label}`, target: { kind: "body", key: nowKey } }); + const same = (a: BoardSelection, b: NonNullable) => a?.kind === b.kind + && (a.kind === "house" ? b.kind === "house" && a.house === b.house : b.kind === "body" && a.key === b.key); + return ( +
+ {tags.map((tag) => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/chart-board/chart-type-table.tsx b/frontend/src/components/chart-board/chart-type-table.tsx new file mode 100644 index 00000000..93fb3377 --- /dev/null +++ b/frontend/src/components/chart-board/chart-type-table.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + BhavaTables, + NatalTable, + PrecisionHint, + TransitTable, + VargaTable, +} from "@/components/chart-page/chart-vedic-tab"; +import type { ChartViewOk } from "@/lib/chart-view-contract"; +import { CHART_VIEW_COPY, type ChartTypeId } from "@/lib/chart-view-labels"; + +/** + * The selected chart type's own table, under the inspector. D1 has none here: + * the 九曜 card is the natal table. + */ +export function ChartTypeTable({ view, type }: { view: ChartViewOk; type: ChartTypeId }) { + if (type === "D1") return null; + let body: ReactNode = null; + let title = ""; + if (type === "MOON" && view.vedic.moon) { + title = "月亮盘"; + body = ( + + ); + } else if (type === "BHAVA" && view.bhava.status === "ok") { + title = "Bhava 盘"; + body = ( + <> +

{CHART_VIEW_COPY.bhavaNote}

+ + + ); + } else if (type === "TRANSIT" && view.transit.status === "ok") { + title = "行运"; + body = ( + <> +

{CHART_VIEW_COPY.transitNote}

+ + + ); + } else { + const varga = view.vedic.vargas.find((item) => item.id === type); + if (varga?.rows) { + title = `${varga.id} · ${varga.meaning}`; + body = ( + <> + + + + ); + } + } + if (!body) return null; + return ( + + + {title} + + {body} + + ); +} diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index 4c160c8f..10ce6198 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 { useRouter } from "next/navigation"; import { Pencil } from "lucide-react"; import { OnboardingDot } from "@/components/onboarding-dot"; @@ -8,29 +9,41 @@ import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { BirthSkyEntry } from "@/components/birth-sky/birth-sky-entry"; +import { ChartBoardCard } from "@/components/chart-board/chart-board-card"; +import { ChartDeepReading, type ChartDeepTab } from "@/components/chart-board/chart-deep-reading"; +import { ChartInspector } from "@/components/chart-board/chart-inspector"; +import { + ChartDashaStripCard, + ChartGlanceCard, + ChartGrahaCard, + ChartHouseThemesCard, + ChartTags, +} from "@/components/chart-board/chart-side-cards"; +import { ChartTypeTable } from "@/components/chart-board/chart-type-table"; import { ChartBirthEditor } from "@/components/chart-page/chart-birth-editor"; import { SecondaryPageShell } from "@/components/secondary-page-shell"; import { useBirthSkyCover } from "@/hooks/use-birth-sky-cover"; +import { useChartBoard } from "@/hooks/use-chart-board"; import { useChartPage } from "@/hooks/use-chart-page"; +import { useTransitAt } from "@/hooks/use-transit-at"; import type { BirthSkyCover } from "@/lib/birth-sky/cover"; import { - CHART_VIEW_TABS, type ChartViewFailureStatus, type ChartViewLagnaPrecheck, + type ChartViewOk, type ChartViewResponse, type ChartViewTabId, } from "@/lib/chart-view-contract"; import { rectificationEntryHref } from "@/lib/chat-session-url"; +import { setComposerDraft } from "@/lib/composer-draft"; import { lagnaPrecheckLine } from "@/lib/lagna-precheck"; import type { ChartViewLayer } from "@/lib/chart-view-engine"; -import { CHART_VIEW_COPY, type ChartTypeId } from "@/lib/chart-view-labels"; +import { CHART_VIEW_COPY } from "@/lib/chart-view-labels"; +import { notifyInfo } from "@/lib/notify"; import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey"; import { CHART_SETTINGS_HREF } from "@/lib/settings-url"; import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; import { ChartPageSkeleton } from "./chart-page-skeleton"; -import { ChartBasicsTab } from "./chart-basics-tab"; -import { ChartDashaTab } from "./chart-dasha-tab"; -import { ChartVedicTab } from "./chart-vedic-tab"; import { ChartWesternTab } from "./chart-western-tab"; export function ChartPageRoute() { @@ -38,8 +51,14 @@ export function ChartPageRoute() { const ok = view?.status === "ok" ? view : null; // 「那一刻的天空」 is prefetched as soon as the chart is ok (TASK-birth-sky-cover T3). const birthSky = useBirthSkyCover(ok ? ok.subjectId ?? "self" : null, ok?.profileFingerprint ?? null); + const router = useRouter(); return ( { + setComposerDraft(question); + notifyInfo("已填进对话输入框,确认后再发送"); + router.push("/"); + }} view={view} pendingLayers={pendingLayers} layerFailures={layerFailures} @@ -51,21 +70,6 @@ export function ChartPageRoute() { ); } -/** - * The lazy layer a chart type needs. D1 and the Moon chart are projected from - * the natal packet that is already on the page, so they request nothing. - */ -function chartTypeLayer(id: ChartTypeId): ChartViewLayer | null { - if (id === "D1" || id === "MOON") return null; - if (id === "BHAVA") return "bhava"; - if (id === "TRANSIT") return "transit"; - return "varga"; -} - -function WaitingNote({ copy }: { copy: string }) { - return

{copy}

; -} - function ChartLagnaPrecheckNote({ value, self }: { value: ChartViewLagnaPrecheck; self: boolean }) { const line = lagnaPrecheckLine(value, { self }); return ( @@ -90,6 +94,113 @@ function showsProfileEntry(view: ChartViewResponse): boolean { return view.reason != null && PROFILE_ENTRY_REASONS.has(view.reason); } +type ChartSystem = "vedic" | "western"; + +/** Without the app router (tests, error boundaries) the draft survives a full load. */ +function askFromChartPage(question: string) { + setComposerDraft(question); + window.location.assign("/"); +} + +function deepTabFor(tab: ChartViewTabId): ChartDeepTab { + return tab === "basics" ? "book" : "time"; +} + +/** Old tab ids the onboarding hints are keyed by (lib/onboarding-hints.ts TAB_HINT_IDS). */ +function journeyTabOf(system: ChartSystem, deep: ChartDeepTab | null): ChartViewTabId { + if (system === "western") return "western"; + if (deep === "time") return "dasha"; + if (deep === "book") return "basics"; + return "vedic"; +} + +/** + * One page (issue #114 Q2 A): board and inspector beside the 九曜 table, house + * themes, dasha strip and summary; the 「深读」 tabs below. 西洋盘 stays as its + * own page behind the system switch (Q4). + */ +function VedicWorkspace({ view, pendingLayers, layerFailures, onNeedLayer, deepTab, onDeepTab, onAsk }: { + onAsk: (question: string) => void; + view: ChartViewOk; + pendingLayers?: ReadonlySet; + layerFailures?: ReadonlyMap; + onNeedLayer?: (layer: ChartViewLayer) => void; + deepTab: ChartDeepTab; + onDeepTab: (tab: ChartDeepTab) => void; +}) { + const board = useChartBoard({ view, onNeedLayer, pendingLayers, layerFailures }); + const natalModel = board.type === "D1" ? board.model : null; + const [observe, setObserve] = useState(view.dasha.asOf); + const transit = useTransitAt(view, observe, board.needsTransit || deepTab === "time", layerFailures?.has("transit") ?? false); + + function openTime() { + onDeepTab("time"); + document.getElementById("chart-deep")?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + return ( +
+ {board.model ? ( + + ) : null} +
+
+ + board.setType("D9")} + /> + +
+
+ + {board.model ? : null} + + {board.model ? : null} +
+
+ +
+ ); +} + export function ChartPageView({ view, pendingLayers, @@ -99,7 +210,10 @@ export function ChartPageView({ birthSky = null, birthSkyPending = false, onBirthSaved, + onAsk = askFromChartPage, }: { + /** 「问 AI」: fill the chat composer and go to the chat; never send (issue #114 Q7). */ + onAsk?: (question: string) => void; view: ChartViewResponse | null; pendingLayers?: ReadonlySet; layerFailures?: ReadonlyMap; @@ -111,25 +225,26 @@ export function ChartPageView({ birthSkyPending?: boolean; onBirthSaved?: () => Promise | void; }) { - const [tab, setTab] = useState(initialTab); + const [system, setSystem] = useState(initialTab === "western" ? "western" : "vedic"); + const [deepTab, setDeepTab] = useState(deepTabFor(initialTab)); + const [deepTouched, setDeepTouched] = useState(initialTab === "dasha" || initialTab === "basics"); const journey = useOnboardingJourneyOptional(); const setJourneyTab = journey?.setChartTab; const markChartVisited = journey?.markChartVisited; - const [vargaId, setVargaId] = useState("D1"); const [editingBirth, setEditingBirth] = useState(false); - const [dashaSystem, setDashaSystem] = useState<"chara" | "yogini">("chara"); + const journeyTab = journeyTabOf(system, deepTouched ? deepTab : null); useEffect(() => { if (!setJourneyTab) return; const name = view?.status === "ok" && view.subjectId && view.subjectId !== "self" ? view.profile.name.trim() || null : null; - const tabId = view?.status === "ok" ? tab : null; + const tabId = view?.status === "ok" ? journeyTab : null; queueMicrotask(() => { if (view?.status === "ok") markChartVisited?.(); setJourneyTab(tabId, name); }); - }, [markChartVisited, setJourneyTab, tab, view]); + }, [journeyTab, markChartVisited, setJourneyTab, view]); useEffect(() => { return () => { @@ -137,25 +252,16 @@ export function ChartPageView({ }; }, [setJourneyTab]); - function selectTab(id: ChartViewTabId) { - setTab(id); - if (id === "dasha") onNeedLayer?.("chara"); - if (id === "western") onNeedLayer?.("western"); + function selectSystem(next: ChartSystem) { + setSystem(next); + if (next === "western") onNeedLayer?.("western"); } - function selectDashaSystem(id: "chara" | "yogini") { - setDashaSystem(id); - onNeedLayer?.(id); + function selectDeepTab(next: ChartDeepTab) { + setDeepTab(next); + setDeepTouched(true); } - function selectVarga(id: ChartTypeId) { - setVargaId(id); - const layer = chartTypeLayer(id); - if (layer) onNeedLayer?.(layer); - } - - const vedicLayer = chartTypeLayer(vargaId); - const birthline = view != null && view.status === "ok" ? ( <> @@ -178,15 +284,7 @@ export function ChartPageView({ async function afterBirthSaved() { await onBirthSaved?.(); - if (tab === "dasha") { - onNeedLayer?.("chara"); - if (dashaSystem === "yogini") onNeedLayer?.("yogini"); - } - if (tab === "western") onNeedLayer?.("western"); - if (tab === "vedic") { - const layer = chartTypeLayer(vargaId); - if (layer) onNeedLayer?.(layer); - } + if (system === "western") onNeedLayer?.("western"); } return ( @@ -214,56 +312,42 @@ export function ChartPageView({
{ - if (value) selectTab(value as ChartViewTabId); + if (value) selectSystem(value as ChartSystem); }} > - - {CHART_VIEW_TABS.map((item) => ( - - {item.label} - {journey?.tabHasDot(item.id) ? : null} - - ))} + + + 印度盘 + {journey?.tabHasDot("vedic") ? : null} + + + 西洋盘 + {journey?.tabHasDot("western") ? : null} + -
- {view == null ? : <> - {tab === "vedic" ? ( - - ) : null} - {tab === "basics" ? : null} - {tab === "dasha" ? ( - - ) : null} - {tab === "western" ? ( +
+ {view == null ? : system === "western" ? ( pendingLayers?.has("western") ? ( ) : layerFailures?.has("western") ? ( - +

{chartLayerFailureCopy(layerFailures.get("western")!)}

) : ( ) - ) : null} - } + ) : ( + + )}
)} diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index 8e3a14db..be5961f6 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -129,7 +129,7 @@ function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; * The natal eight-column table. Everything the chart stopped drawing lands * here: the chart carries symbols, this table carries the numbers behind them. */ -function NatalTable({ planets, ascendant, ascendantHouse, caption }: { +export function NatalTable({ planets, ascendant, ascendantHouse, caption }: { planets: readonly ChartViewPlanetRow[]; ascendant: ChartViewAscendantRow | null; ascendantHouse?: number; @@ -233,12 +233,12 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) { ); } -function PrecisionHint({ view, id }: { view: ChartViewOk; id: string }) { +export function PrecisionHint({ view, id }: { view: ChartViewOk; id: string }) { if (!BIRTH_TIME_SENSITIVE_VARGAS.has(id) || view.profile.birthTimeStatus === "confirmed") return null; return

{CHART_VIEW_COPY.precisionHint}

; } -function TransitTable({ transit }: { transit: ChartViewTransitOk }) { +export function TransitTable({ transit }: { transit: ChartViewTransitOk }) { return (
@@ -273,7 +273,7 @@ function TransitTable({ transit }: { transit: ChartViewTransitOk }) { ); } -function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) { +export function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) { return ( <>
diff --git a/frontend/src/components/chat-chart-frame.tsx b/frontend/src/components/chat-chart-frame.tsx index 7d84a0b8..67f42ae2 100644 --- a/frontend/src/components/chat-chart-frame.tsx +++ b/frontend/src/components/chat-chart-frame.tsx @@ -1,6 +1,6 @@ "use client"; -import { PanelRightClose, PanelRightOpen } from "lucide-react"; +import { Orbit, PanelRightClose, PanelRightOpen } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { Button } from "@/components/ui/button"; @@ -9,6 +9,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useCurrentSubject } from "@/lib/current-subject"; import { useUiStore } from "@/stores/ui-store"; +type SheetComponent = (props: { open: boolean; onOpenChange: (open: boolean) => void }) => ReactNode; + type SplitComponent = (props: { open: boolean; width: number; @@ -21,7 +23,22 @@ export function ChatChartToggle() { const { isMobile, ready } = useSidebar(); const open = useUiStore((state) => state.chatChartOpen); const setOpen = useUiStore((state) => state.setChatChartOpen); - if (!ready || isMobile) return null; + const setSheetOpen = useUiStore((state) => state.setChatChartSheetOpen); + if (!ready) return null; + if (isMobile) { + return ( + + ); + } const label = open ? "收起本命盘" : "打开本命盘"; const Icon = open ? PanelRightClose : PanelRightOpen; return ( @@ -49,7 +66,9 @@ export function ChatChartToggle() { /** * One cell spanning every row of `.chat-panel`. The inner `.chat-column` * repeats today's row template so secondary pages can keep the outer grid. - * The split (and the chart drawing) load the first time the panel opens. + * The split (and the chart drawing) load the first time the panel opens; it + * starts open (issue #114 Q8 A). Phones load the bottom sheet instead, the + * first time it is asked for. */ export function ChatChartFrame({ children }: { children: ReactNode }) { const { isMobile, ready } = useSidebar(); @@ -61,7 +80,11 @@ export function ChatChartFrame({ children }: { children: ReactNode }) { const setOpen = useUiStore((state) => state.setChatChartOpen); const setWidth = useUiStore((state) => state.setChatChartWidth); const [Split, setSplit] = useState(null); + const [Sheet, setSheet] = useState(null); + const sheetOpen = useUiStore((state) => state.chatChartSheetOpen); + const setSheetOpen = useUiStore((state) => state.setChatChartSheetOpen); const desktop = ready && !isMobile; + const phone = ready && isMobile; useEffect(() => { hydrate(); @@ -70,12 +93,25 @@ export function ChatChartFrame({ children }: { children: ReactNode }) { useEffect(() => { if (!desktop || !hydrated || !open || Split) return; let cancelled = false; + // The board's stylesheet rides along; a failed CSS load never blocks the panel. + void import("@/components/chart-board/chart-board-styles").catch(() => undefined); void import("@/components/chat-chart-split").then((mod) => { if (!cancelled) setSplit(() => mod.ChatChartSplit); }); return () => { cancelled = true; }; }, [Split, desktop, hydrated, open]); + useEffect(() => { + if (!phone || !sheetOpen || Sheet) return; + let cancelled = false; + // The board's stylesheet rides along; a failed CSS load never blocks the panel. + void import("@/components/chart-board/chart-board-styles").catch(() => undefined); + void import("@/components/chat-chart-sheet").then((mod) => { + if (!cancelled) setSheet(() => mod.ChatChartSheet); + }); + return () => { cancelled = true; }; + }, [Sheet, phone, sheetOpen]); + const column =
{children}
; return (
@@ -83,6 +119,7 @@ export function ChatChartFrame({ children }: { children: ReactNode }) { {Split && desktop ? ( ) : null} + {Sheet && phone ? : null}
); } diff --git a/frontend/src/components/chat-chart-sheet.tsx b/frontend/src/components/chat-chart-sheet.tsx new file mode 100644 index 00000000..c012ff52 --- /dev/null +++ b/frontend/src/components/chat-chart-sheet.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { ChatNatalChart } from "@/components/chat-natal-chart"; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer"; + +/** + * Phones: the chat's board as a bottom sheet (issue #114 Q8, agreed + * 2026-10-27). Loaded the first time the sheet opens; 「问 AI」 closes it so + * the composer with the filled-in question is in view. + */ +export function ChatChartSheet({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + return ( + + + + 本命盘 + +
+ onOpenChange(false)} /> +
+
+
+ ); +} diff --git a/frontend/src/components/chat-natal-chart.tsx b/frontend/src/components/chat-natal-chart.tsx index 1c0bfe2c..983ed394 100644 --- a/frontend/src/components/chat-natal-chart.tsx +++ b/frontend/src/components/chat-natal-chart.tsx @@ -1,56 +1,100 @@ "use client"; -import { useEffect, useState } from "react"; +import Link from "next/link"; -import { fetchChartView } from "@/lib/chart-view-client"; -import type { ChartViewResponse, NorthIndianChart } from "@/lib/chart-view-contract"; -import { peekChartPage } from "@/lib/secondary-page-data"; +import { ChartBoardCard } from "@/components/chart-board/chart-board-card"; +import { ChartInspector } from "@/components/chart-board/chart-inspector"; +import { ChartGrahaCard, ChartTags } from "@/components/chart-board/chart-side-cards"; +import { Button } from "@/components/ui/button"; +import { useChartBoard } from "@/hooks/use-chart-board"; +import { useChartPage } from "@/hooks/use-chart-page"; +import { useTransitAt } from "@/hooks/use-transit-at"; +import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract"; +import type { ChartViewLayer } from "@/lib/chart-view-engine"; +import { setComposerDraft } from "@/lib/composer-draft"; +import { notifyInfo } from "@/lib/notify"; -type ChartDrawing = typeof import("@/components/personal-report/vedic-chart-svg").VedicChartSvg; +/** Put the question in the chat composer and focus it; never send (issue #114 Q7). */ +function askInComposer(question: string, onAsked?: () => void) { + setComposerDraft(question); + onAsked?.(); + requestAnimationFrame(() => { + const input = document.querySelector("form.composer textarea"); + input?.focus({ preventScroll: true }); + input?.setSelectionRange(input.value.length, input.value.length); + }); + notifyInfo("已填进输入框,确认后再发送"); +} -function natalChart(view: ChartViewResponse | null, subjectId: string): NorthIndianChart | null { - if (!view || view.status !== "ok" || view.subjectId !== subjectId) return null; - const varga = view.vedic.vargas.find((item) => item.id === "D1") ?? view.vedic.vargas[0]; - return varga?.chart ?? null; +function PanelBoard({ view, pendingLayers, layerFailures, onNeedLayer, onAsked }: { + view: ChartViewOk; + pendingLayers: ReadonlySet; + layerFailures: ReadonlyMap; + onNeedLayer: (layer: ChartViewLayer) => void; + onAsked?: () => void; +}) { + const board = useChartBoard({ view, onNeedLayer, pendingLayers, layerFailures }); + const transit = useTransitAt(view, view.dasha.asOf, board.needsTransit, layerFailures.has("transit")); + return ( +
+
+ {view.profile.name} · 本命盘 + +
+ {board.model ? : null} + + askInComposer(question, onAsked)} + onLocateD9={board.type === "D9" ? undefined : () => board.setType("D9")} + /> + +
+ ); } /** - * Current person's D1, drawn with the chart page's SVG. Loaded only after the - * panel opens. No skeleton and no "正在加载": the region stays quiet until the - * drawing is ready. Do not mark `data-onboarding-draw` here; that key belongs - * to the chart page's first draw. + * The chat's natal panel (issue #114 M8): the same clickable board as the + * chart page, for the person the chat is about. It is there as soon as the + * chat opens; no question is needed first (Q8 A). No skeleton and no + * 「正在加载」: the region stays quiet until the chart is ready. */ -export function ChatNatalChart({ subjectId }: { subjectId: string }) { - const peeked = peekChartPage(); - const cached = peeked?.kind === "view" ? natalChart(peeked.view, subjectId) : null; - const [remote, setRemote] = useState<{ subjectId: string; chart: NorthIndianChart | null } | null>(null); - const [Drawing, setDrawing] = useState(null); - const chart = remote?.subjectId === subjectId ? remote.chart ?? cached : cached; - - useEffect(() => { - let cancelled = false; - void import("@/components/personal-report/vedic-chart-svg").then((mod) => { - if (!cancelled) setDrawing(() => mod.VedicChartSvg); - }); - return () => { cancelled = true; }; - }, []); - - useEffect(() => { - if (cached) return; - const controller = new AbortController(); - void fetchChartView({ layers: ["varga"], signal: controller.signal, subjectId }) - .then((result) => { - if (controller.signal.aborted) return; - setRemote({ subjectId, chart: natalChart(result.body, subjectId) }); - }) - .catch(() => undefined); - return () => controller.abort(); - }, [cached, subjectId]); - +export function ChatNatalChart({ onAsked }: { subjectId?: string; onAsked?: () => void }) { + const { view, pendingLayers, layerFailures, requestLayer } = useChartPage(); return (
- {chart && Drawing ? ( - + {view?.status === "ok" ? ( + + ) : view ? ( +

{view.message}

) : (
)} diff --git a/frontend/src/hooks/use-chart-board.ts b/frontend/src/hooks/use-chart-board.ts new file mode 100644 index 00000000..d50c9df5 --- /dev/null +++ b/frontend/src/hooks/use-chart-board.ts @@ -0,0 +1,140 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react"; + +import { stepSelection, type BoardSelection } from "@/lib/chart-board-model"; +import { boardModelFor, boardSource, type ChartBoardLayer } from "@/lib/chart-board-view"; +import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract"; +import type { ChartViewLayer } from "@/lib/chart-view-engine"; +import type { ChartTypeId } from "@/lib/chart-view-labels"; +import type { PlanetGlyphKey } from "@/lib/planet-glyphs"; +import { useUiStore } from "@/stores/ui-store"; + +/** The lazy layer a chart type needs; D1 and the Moon chart come with the page. */ +export function chartTypeLayer(id: ChartTypeId): ChartViewLayer | null { + if (id === "D1" || id === "MOON") return null; + if (id === "BHAVA") return "bhava"; + if (id === "TRANSIT") return "transit"; + return "varga"; +} + +function overlayLayer(layer: ChartBoardLayer): ChartViewLayer | null { + if (layer === "sav" || layer === "bhava") return "ashtakavarga"; + // Today's sky; other observation days are fetched by useTransitAt. + if (layer === "transit") return "transit"; + return null; +} + +/** + * Board state shared by the chart page and the chat panel: chart type, + * selection, overlay layer, and the North/South choice (site-wide, persisted). + * Lazy layers are requested from the handlers that need them, never from an + * effect. + */ +export function useChartBoard({ + view, + onNeedLayer, + pendingLayers, + layerFailures, +}: { + view: ChartViewOk; + onNeedLayer?: (layer: ChartViewLayer) => void; + pendingLayers?: ReadonlySet; + layerFailures?: ReadonlyMap; +}) { + const style = useUiStore((state) => state.chartBoardStyle); + const setStyle = useUiStore((state) => state.setChartBoardStyle); + const hydrateStyle = useUiStore((state) => state.hydrateChartBoardStyle); + const [type, setTypeState] = useState("D1"); + const [selection, setSelection] = useState(null); + const [layer, setLayerState] = useState("none"); + const [yogaBodies, setYogaBodies] = useState(null); + + useEffect(() => { + hydrateStyle(); + }, [hydrateStyle]); + + const source = useMemo(() => boardSource(view, type), [type, view]); + const model = useMemo(() => (source ? boardModelFor(source) : null), [source]); + + const setType = useCallback((next: ChartTypeId) => { + setTypeState(next); + const needed = chartTypeLayer(next); + if (needed) onNeedLayer?.(needed); + }, [onNeedLayer]); + + const setLayer = useCallback((next: ChartBoardLayer) => { + setLayerState(next); + const needed = overlayLayer(next); + if (needed) onNeedLayer?.(needed); + }, [onNeedLayer]); + + const select = useCallback((next: BoardSelection) => { + setSelection(next); + setYogaBodies(null); + // The inspector's 「此刻行运」, 宫力 and 八宫点 read these layers. + if (next?.kind === "body") onNeedLayer?.("transit"); + if (next?.kind === "house") onNeedLayer?.("ashtakavarga"); + }, [onNeedLayer]); + + const step = useCallback((direction: 1 | -1) => { + if (!model) return; + setSelection((current) => stepSelection(model, current, direction)); + }, [model]); + + const focusYoga = useCallback((bodies: readonly PlanetGlyphKey[] | null) => { + setSelection(null); + setYogaBodies(bodies && bodies.length > 0 ? bodies : null); + setTypeState("D1"); + }, []); + + /** ← / → step, Esc clears; ignored while typing. */ + const onKeyDown = useCallback((event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if (target?.closest?.("input, textarea, [contenteditable='true']")) return; + if (event.key === "Escape" && (selection || yogaBodies)) { + setSelection(null); + setYogaBodies(null); + return; + } + if (!selection) return; + if (event.key === "ArrowRight") { event.preventDefault(); step(1); } + if (event.key === "ArrowLeft") { event.preventDefault(); step(-1); } + }, [selection, step, yogaBodies]); + + const typeLayer = chartTypeLayer(type); + const overlay = overlayLayer(layer); + const dimmedBodies = useMemo(() => { + if (!yogaBodies || !model) return undefined; + const keep = new Set(yogaBodies); + const dim = new Set(); + for (const house of model.houses) for (const body of house.bodies) if (!keep.has(body.key)) dim.add(body.key); + return dim; + }, [model, yogaBodies]); + + return { + type, + setType, + source, + model, + selection, + select, + step, + layer, + setLayer, + style, + setStyle, + onKeyDown, + yogaBodies, + focusYoga, + dimmedBodies, + /** The board or inspector shows a transit: fetch the observation day's sky. */ + needsTransit: layer === "transit" || selection?.kind === "body", + typePending: typeLayer ? pendingLayers?.has(typeLayer) ?? false : false, + typeFailure: typeLayer ? layerFailures?.get(typeLayer) : undefined, + layerPending: overlay ? pendingLayers?.has(overlay) ?? false : false, + layerFailure: overlay ? layerFailures?.get(overlay) : undefined, + }; +} + +export type ChartBoardState = ReturnType; diff --git a/frontend/src/hooks/use-chart-page.ts b/frontend/src/hooks/use-chart-page.ts index bc4a68a0..697473d1 100644 --- a/frontend/src/hooks/use-chart-page.ts +++ b/frontend/src/hooks/use-chart-page.ts @@ -51,6 +51,7 @@ function mergeLayer(current: ChartViewOk, incoming: ChartViewOk, layer: ChartVie ...(layer === "western" ? { western: incoming.western } : {}), ...(layer === "bhava" ? { bhava: incoming.bhava } : {}), ...(layer === "transit" ? { transit: incoming.transit } : {}), + ...(layer === "ashtakavarga" ? { ashtakavarga: incoming.ashtakavarga } : {}), }; } @@ -144,7 +145,8 @@ export function useChartPage() { const missing = layer === "varga" ? !VARGA_CHIP_ORDER.every((division) => body.vedic.vargas.some((varga) => varga.id === `D${division}`)) : dashaSystem ? body.dasha[dashaSystem.track].periods.length === 0 : layer === "western" || layer === "bhava" || layer === "transit" ? body[layer].status !== "ok" - : true; + : layer === "ashtakavarga" ? body.ashtakavarga?.status !== "ok" + : true; if (missing) { fail("unavailable"); return; } const current = peekChartPage(); const merged = current?.kind === "view" && current.view.status === "ok" diff --git a/frontend/src/hooks/use-transit-at.ts b/frontend/src/hooks/use-transit-at.ts new file mode 100644 index 00000000..be8df514 --- /dev/null +++ b/frontend/src/hooks/use-transit-at.ts @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { fetchChartView } from "@/lib/chart-view-client"; +import type { ChartViewOk, ChartViewTransitOk } from "@/lib/chart-view-contract"; +import { CHART_VIEW_CLIENT_TIMEOUT_MS } from "@/lib/chart-view-labels"; + +/** Wait this long after the last date change before asking the server (rate limit is 20/min). */ +const TRANSIT_DEBOUNCE_MS = 400; + +type Fetched = { date: string; transit: ChartViewTransitOk | null }; + +/** + * Transit for any observation day (issue #114 Q9). Today is the page's own + * `transit` layer (the caller requests it); other days are fetched here and + * kept out of the shared page snapshot. `transit` is null while the day is + * loading or failed. + */ +export function useTransitAt(view: ChartViewOk, date: string, enabled: boolean, todayFailed = false): { + transit: ChartViewTransitOk | null; + pending: boolean; + failed: boolean; +} { + const own = view.transit.status === "ok" && view.transit.date === date ? view.transit : null; + const isToday = date === view.dasha.asOf; + const [fetched, setFetched] = useState(null); + const subjectId = view.subjectId; + + useEffect(() => { + if (own || !enabled || isToday) return; + const controller = new AbortController(); + let cancelled = false; + let deadline: ReturnType | undefined; + const timer = setTimeout(() => { + deadline = setTimeout(() => controller.abort(), CHART_VIEW_CLIENT_TIMEOUT_MS); + void fetchChartView({ + layers: ["transit"], + subjectId, + transitDate: date, + signal: controller.signal, + }) + .then((result) => { + if (cancelled) return; + const body = result.body; + const transit = body?.status === "ok" && body.transit.status === "ok" && body.transit.date === date ? body.transit : null; + setFetched({ date, transit }); + }) + .catch(() => { + // A deadline abort is a failure; an unmount or date change is not. + if (!cancelled) setFetched({ date, transit: null }); + }) + .finally(() => clearTimeout(deadline)); + }, TRANSIT_DEBOUNCE_MS); + return () => { + cancelled = true; + clearTimeout(timer); + clearTimeout(deadline); + controller.abort(); + }; + }, [date, enabled, isToday, own, subjectId]); + + if (own) return { transit: own, pending: false, failed: false }; + if (!enabled) return { transit: null, pending: false, failed: false }; + if (isToday) return { transit: null, pending: !todayFailed, failed: todayFailed }; + const settled = fetched?.date === date ? fetched : null; + return { transit: settled?.transit ?? null, pending: !settled, failed: settled !== null && settled.transit === null }; +} diff --git a/frontend/src/lib/chart-board-model.ts b/frontend/src/lib/chart-board-model.ts new file mode 100644 index 00000000..4b4fcef6 --- /dev/null +++ b/frontend/src/lib/chart-board-model.ts @@ -0,0 +1,182 @@ +/** + * The clickable chart board (issue #114): which houses a body rules and + * aspects, which body rules a house, and what the board highlights for a + * selection. Pure functions over the chart the page already has; the rules are + * the engine's (sign lords; graha drishti as in scripts/jyotish_engine.py, + * nodes 5/7/9), counted whole-sign from the house a body sits in. + */ +import type { NorthIndianChart } from "./chart-view-contract.ts"; +import { SIGN_ORDER } from "./chart-view-labels.ts"; +import { PLANET_GLYPH_KEYS, type PlanetGlyphKey } from "./planet-glyphs.ts"; + +/** Lord of each sign, Aries first. */ +export const SIGN_LORD: readonly PlanetGlyphKey[] = [ + "mars", "venus", "mercury", "moon", "sun", "mercury", + "venus", "mars", "jupiter", "saturn", "saturn", "jupiter", +]; + +/** Houses counted from the body's own house (1 = its own). */ +export const GRAHA_DRISHTI: Readonly> = { + sun: [7], + moon: [7], + mars: [4, 7, 8], + mercury: [7], + jupiter: [5, 7, 9], + venus: [7], + saturn: [3, 7, 10], + rahu: [5, 7, 9], + ketu: [5, 7, 9], +}; + +export type BoardBody = { + key: PlanetGlyphKey; + house: number; + sign: number; + degree: number; + retrograde: boolean; +}; + +export type BoardHouse = { + house: number; + /** 0 = Aries. */ + sign: number; + bodies: BoardBody[]; +}; + +export type BoardModel = { + houses: BoardHouse[]; + /** House holding the natal ascendant: 1, except on the Moon chart. */ + ascendantHouse: number; +}; + +export type BoardSelection = + | { kind: "body"; key: PlanetGlyphKey } + | { kind: "house"; house: number } + | null; + +export function signIndex(sign: string): number { + return (SIGN_ORDER as readonly string[]).indexOf(sign); +} + +/** Wrap a house number into 1..12. */ +export function wrapHouse(house: number): number { + return ((((house - 1) % 12) + 12) % 12) + 1; +} + +export function boardFromChart(chart: NorthIndianChart, ascendantHouse = 1): BoardModel { + const houses = [...chart.houses] + .sort((a, b) => a.houseNumber - b.houseNumber) + .map((house) => { + const sign = signIndex(house.sign); + return { + house: house.houseNumber, + sign, + bodies: (house.occupantGlyphs ?? []).map((glyph) => ({ + key: glyph.key, + house: house.houseNumber, + sign, + degree: glyph.degree, + retrograde: glyph.retrograde, + })), + }; + }); + return { houses, ascendantHouse }; +} + +export function bodyHouse(model: BoardModel, key: PlanetGlyphKey): number | null { + for (const house of model.houses) { + if (house.bodies.some((body) => body.key === key)) return house.house; + } + return null; +} + +export function houseLord(model: BoardModel, house: number): PlanetGlyphKey | null { + const sign = model.houses[house - 1]?.sign; + return sign === undefined || sign < 0 ? null : SIGN_LORD[sign] ?? null; +} + +/** Houses whose sign the body rules. Nodes rule none. */ +export function lordedHouses(model: BoardModel, key: PlanetGlyphKey): number[] { + return model.houses.filter((house) => house.sign >= 0 && SIGN_LORD[house.sign] === key).map((house) => house.house); +} + +export function aspectedHouses(model: BoardModel, key: PlanetGlyphKey): number[] { + const from = bodyHouse(model, key); + if (from === null) return []; + return GRAHA_DRISHTI[key].map((count) => wrapHouse(from + count - 1)); +} + +/** Bodies whose graha drishti falls on the house. */ +export function bodiesAspecting(model: BoardModel, house: number): PlanetGlyphKey[] { + return PLANET_GLYPH_KEYS.filter((key) => aspectedHouses(model, key).includes(house)); +} + +export type BoardRelations = { + /** The house the selection sits in; drawn as selected. */ + focus: number | null; + /** Solid lines: from the focus to these houses. */ + lordLinks: number[]; + /** Dashed lines: between the focus and these houses. */ + aspectLinks: number[]; + /** Every house the board keeps at full strength. */ + lit: ReadonlySet; +}; + +/** + * A body links to the houses it rules (solid) and aspects (dashed). A house + * links to where its lord sits (solid) and to the bodies aspecting it (dashed). + */ +export function boardRelations(model: BoardModel, selection: BoardSelection): BoardRelations { + if (!selection) return { focus: null, lordLinks: [], aspectLinks: [], lit: new Set() }; + if (selection.kind === "body") { + const focus = bodyHouse(model, selection.key); + if (focus === null) return { focus: null, lordLinks: [], aspectLinks: [], lit: new Set() }; + const lordLinks = lordedHouses(model, selection.key).filter((house) => house !== focus); + const aspectLinks = aspectedHouses(model, selection.key).filter((house) => house !== focus); + return { focus, lordLinks, aspectLinks, lit: new Set([focus, ...lordLinks, ...aspectLinks]) }; + } + const focus = selection.house; + const lord = houseLord(model, focus); + const lordHouse = lord ? bodyHouse(model, lord) : null; + const lordLinks = lordHouse !== null && lordHouse !== focus ? [lordHouse] : []; + const aspectLinks = [...new Set(bodiesAspecting(model, focus) + .map((key) => bodyHouse(model, key)) + .filter((house): house is number => house !== null && house !== focus))]; + return { focus, lordLinks, aspectLinks, lit: new Set([focus, ...lordLinks, ...aspectLinks]) }; +} + +/** Next body (or house) for the ← / → keys, in table order. */ +export function stepSelection(model: BoardModel, selection: BoardSelection, direction: 1 | -1): BoardSelection { + if (!selection) return null; + if (selection.kind === "house") return { kind: "house", house: wrapHouse(selection.house + direction) }; + const present = PLANET_GLYPH_KEYS.filter((key) => bodyHouse(model, key) !== null); + const index = present.indexOf(selection.key); + if (index < 0 || present.length === 0) return selection; + return { kind: "body", key: present[(index + direction + present.length) % present.length]! }; +} + +/** + * Dignity on a nine-step scale (落陷 … 入旺), read from the engine's status + * label (DIGNITY_LABELS in scripts/jyotish_engine.py). Unknown text → null. + */ +const DIGNITY_STEPS: ReadonlyArray = [ + ["落陷取消", 1, "落陷取消"], + ["落陷", 0, "落陷"], + ["极敌", 1, "极敌"], + ["入敌", 2, "入敌"], + ["中性", 3, "中性"], + ["入友", 4, "入友"], + ["极友", 5, "极友"], + ["入庙", 6, "入庙"], + ["本垣", 7, "本垣"], + ["入旺", 8, "入旺"], +]; + +export const DIGNITY_STEP_COUNT = 9; + +export function dignityStep(status: string): { step: number; label: string } | null { + for (const [prefix, step, label] of DIGNITY_STEPS) { + if (status.startsWith(prefix)) return { step, label }; + } + return null; +} diff --git a/frontend/src/lib/chart-board-style.ts b/frontend/src/lib/chart-board-style.ts new file mode 100644 index 00000000..3e3672b5 --- /dev/null +++ b/frontend/src/lib/chart-board-style.ts @@ -0,0 +1,34 @@ +/** + * North or South Indian board (issue #114 Q3): North by default, one choice + * for the whole site, kept in localStorage like the chat panel width. + */ + +export type ChartBoardStyle = "north" | "south"; + +export const CHART_BOARD_STYLE_STORAGE_KEY = "chart_board_style"; + +type StyleStorage = Pick; + +function storage(): StyleStorage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return null; + } +} + +export function readStoredChartBoardStyle(store: StyleStorage | null = storage()): ChartBoardStyle { + try { + return store?.getItem(CHART_BOARD_STYLE_STORAGE_KEY) === "south" ? "south" : "north"; + } catch { + return "north"; + } +} + +export function writeStoredChartBoardStyle(style: ChartBoardStyle, store: StyleStorage | null = storage()): void { + try { + store?.setItem(CHART_BOARD_STYLE_STORAGE_KEY, style); + } catch { + // Private mode: the choice lasts for this page only. + } +} diff --git a/frontend/src/lib/chart-board-view.ts b/frontend/src/lib/chart-board-view.ts new file mode 100644 index 00000000..5d01327c --- /dev/null +++ b/frontend/src/lib/chart-board-view.ts @@ -0,0 +1,191 @@ +/** + * What the chart page's cards show, read from the chart view (issue #114). + * Pure: the page and the chat panel both call it with the same view. + */ +import { boardFromChart, signIndex, type BoardModel } from "./chart-board-model.ts"; +import type { + ChartViewOk, + ChartViewPlanetRow, + ChartViewShadbalaRow, + ChartViewTransitOk, + NorthIndianChart, +} from "./chart-view-contract.ts"; +import { SIGN_ORDER, signZh, type ChartTypeId } from "./chart-view-labels.ts"; +import { planetGlyphKey, type PlanetGlyphKey } from "./planet-glyphs.ts"; + +export const HOUSE_THEME_ZH = [ + "自我 · 身体", "财帛 · 家族", "勇气 · 手足", "母亲 · 居所", + "子女 · 创造", "疾病 · 竞争", "伴侣 · 合作", "变故 · 隐秘", + "福德 · 师长", "事业 · 名望", "收益 · 朋友", "损耗 · 远方", +] as const; + +export type ChartBoardLayer = "none" | "sav" | "bhava" | "transit"; + +/** Traditional reference line for SAV; it is not this chart's average. */ +export const SAV_REFERENCE = 28; + +export type BoardSource = { + chart: NorthIndianChart; + ascendantHouse: number; + /** "D9", "月亮盘" … for headings and the aria label. */ + title: string; + /** Layers are drawn on the natal D1 only. */ + natal: boolean; +}; + +/** + * The chart behind the selected type, or null while its lazy layer is still + * loading or failed (the caller shows the waiting note). + */ +export function boardSource(view: ChartViewOk, type: ChartTypeId): BoardSource | null { + if (type === "MOON") { + const moon = view.vedic.moon; + return moon ? { chart: moon.chart, ascendantHouse: moon.ascendantHouse, title: "月亮盘", natal: false } : null; + } + if (type === "BHAVA") { + return view.bhava.status === "ok" ? { chart: view.bhava.chart, ascendantHouse: 1, title: "Bhava 盘", natal: false } : null; + } + if (type === "TRANSIT") { + return view.transit.status === "ok" ? { chart: view.transit.chart, ascendantHouse: 1, title: `行运 ${view.transit.date}`, natal: false } : null; + } + const varga = view.vedic.vargas.find((item) => item.id === type) + ?? (type === "D1" ? view.vedic.vargas[0] : undefined); + if (!varga) return null; + return { chart: varga.chart, ascendantHouse: 1, title: type === "D1" ? "本命盘 D1" : `${varga.id} · ${varga.meaning}`, natal: type === "D1" }; +} + +export function boardModelFor(source: BoardSource): BoardModel { + return boardFromChart(source.chart, source.ascendantHouse); +} + +export function natalBoard(view: ChartViewOk): BoardModel { + return boardModelFor(boardSource(view, "D1")!); +} + +export type StrengthBand = "strong" | "medium" | "weak"; +export const BAND_ZH: Readonly> = { strong: "强", medium: "中", weak: "弱" }; + +export function savBand(points: number): StrengthBand { + if (points >= SAV_REFERENCE) return "strong"; + if (points >= 25) return "medium"; + return "weak"; +} + +/** House strength against this chart's own twelve-house mean (±10 %). */ +export function bhavaBand(total: number, mean: number): StrengthBand { + if (total >= mean * 1.1) return "strong"; + if (total >= mean * 0.9) return "medium"; + return "weak"; +} + +export function bhavaMean(view: ChartViewOk): number | null { + const houses = view.ashtakavarga?.status === "ok" ? view.ashtakavarga.bhavaBala : null; + if (!houses) return null; + return houses.reduce((sum, house) => sum + house.total, 0) / houses.length; +} + +/** SAV of the sign a house holds, by house − 1. */ +export function savByHouse(view: ChartViewOk, model: BoardModel): (number | null)[] { + const sav = view.ashtakavarga?.status === "ok" ? view.ashtakavarga.sav : null; + return model.houses.map((house) => (sav && house.sign >= 0 ? sav[house.sign] ?? null : null)); +} + +export function bhavaByHouse(view: ChartViewOk): (number | null)[] { + const houses = view.ashtakavarga?.status === "ok" ? view.ashtakavarga.bhavaBala : null; + return Array.from({ length: 12 }, (_, index) => houses?.[index]?.total ?? null); +} + +/** Transit bodies by sign, for the overlay and the inspector's 「此刻行运」. */ +export function transitBySign(transit: ChartViewTransitOk | null): { key: PlanetGlyphKey; sign: number; degree: number; retrograde: boolean }[] { + if (!transit) return []; + return transit.rows.flatMap((row) => { + const key = planetGlyphKey(row.name); + const sign = signIndex(row.sign); + return key && sign >= 0 ? [{ key, sign, degree: row.degreeInSign, retrograde: row.retrograde }] : []; + }); +} + +export function planetRowFor(view: ChartViewOk, key: PlanetGlyphKey): ChartViewPlanetRow | null { + return view.vedic.planets.find((row) => planetGlyphKey(row.name) === key) ?? null; +} + +export function shadbalaFor(view: ChartViewOk, key: PlanetGlyphKey): ChartViewShadbalaRow | null { + return view.shadbala?.find((row) => planetGlyphKey(row.name) === key) ?? null; +} + +/** Absolute sidereal longitude of a natal body, from sign and in-sign degree. */ +export function natalLongitude(row: ChartViewPlanetRow): number { + return Math.max(0, signIndex(row.sign)) * 30 + row.degreeInSign; +} + +const NAKSHATRA_SPAN = 360 / 27; + +/** Nakshatra boundaries that fall inside the sign, as in-sign degrees. */ +export function nakshatraTicks(sign: number): number[] { + const ticks: number[] = []; + for (let index = 1; index < 27; index += 1) { + const at = index * NAKSHATRA_SPAN - sign * 30; + if (at > 0.01 && at < 29.99) ticks.push(at); + } + return ticks; +} + +export type VimshottariNow = { + maha: ChartViewOk["dasha"]["vimshottari"]["periods"][number]; + antar: ChartViewOk["dasha"]["vimshottari"]["periods"][number]["antardashas"][number] | null; +}; + +/** Mahadasha and antardasha containing the day (ISO yyyy-mm-dd). */ +export function vimshottariAt(view: ChartViewOk, day: string): VimshottariNow | null { + const maha = view.dasha.vimshottari.periods.find((period) => period.start <= day && day < period.end); + if (!maha) return null; + const antar = maha.antardashas.find((period) => period.start <= day && day < period.end) ?? null; + return { maha, antar }; +} + +export function progressOf(start: string, end: string, day: string): number { + const a = Date.parse(`${start}T00:00:00Z`); + const b = Date.parse(`${end}T00:00:00Z`); + const t = Date.parse(`${day}T00:00:00Z`); + if (!(b > a)) return 0; + return Math.min(1, Math.max(0, (t - a) / (b - a))); +} + +export type ChartGlance = { + ascendant: { sign: string; degree: number } | null; + moon: { sign: string; nakshatra: string } | null; + atmakaraka: { key: PlanetGlyphKey; label: string; sign: string; degree: number } | null; + dasha: VimshottariNow | null; + strongest: ChartViewShadbalaRow | null; + weakest: ChartViewShadbalaRow | null; +}; + +/** + * Atmakaraka: the body with the highest in-sign degree among the seven + * (the Chara karaka scheme the engine uses, nodes excluded). + */ +export function glanceOf(view: ChartViewOk, day: string): ChartGlance { + const moonRow = view.vedic.planets.find((row) => row.name === "Moon") ?? null; + const seven = view.vedic.planets.filter((row) => row.name !== "Rahu" && row.name !== "Ketu"); + const ak = seven.reduce((best, row) => (!best || row.degreeInSign > best.degreeInSign ? row : best), null); + const akKey = ak ? planetGlyphKey(ak.name) : null; + const ranked = [...(view.shadbala ?? [])].sort((a, b) => b.percent - a.percent); + return { + ascendant: view.vedic.ascendant ? { sign: view.vedic.ascendant.signLabel, degree: view.vedic.ascendant.degreeInSign } : null, + moon: moonRow ? { sign: moonRow.signLabel, nakshatra: moonRow.nakshatra } : null, + atmakaraka: ak && akKey ? { key: akKey, label: ak.label, sign: ak.signLabel, degree: ak.degreeInSign } : null, + dasha: vimshottariAt(view, day), + strongest: ranked[0] ?? null, + weakest: ranked.at(-1) ?? null, + }; +} + +/** Sign name for a 0-based index. */ +export function signLabelAt(sign: number): string { + return signZh(SIGN_ORDER[sign] ?? ""); +} + +/** Today in the page's own calendar (the dasha `asOf` the server stamped). */ +export function chartToday(view: ChartViewOk): string { + return view.dasha.asOf; +} diff --git a/frontend/src/lib/chart-view-client.ts b/frontend/src/lib/chart-view-client.ts index 0fed8849..4f0fe6a5 100644 --- a/frontend/src/lib/chart-view-client.ts +++ b/frontend/src/lib/chart-view-client.ts @@ -6,10 +6,13 @@ export async function fetchChartView(input: { layers?: readonly ChartViewLayer[]; signal?: AbortSignal; subjectId?: string; + /** Transit layer day (YYYY-MM-DD); the server uses today when absent. */ + transitDate?: string; }): Promise<{ httpStatus: number; body: ChartViewResponse | null }> { const params = new URLSearchParams(); if (input.layers?.length) params.set("layers", input.layers.join(",")); if (input.subjectId) params.set("subject", input.subjectId); + if (input.transitDate) params.set("transitDate", input.transitDate); const serialized = params.toString(); const query = serialized ? `?${serialized}` : ""; const response = await fetch(`/api/chart-view${query}`, { diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 744d9b91..592c3c7f 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -248,6 +248,84 @@ export const chartViewWesternOkSchema = z.object({ })), }); +/** + * Shadbala per body, from the natal `/api/chart` packet (`modules.shadbala`, + * issue #114). `parts` are the six components in virupas; older packets that + * only carry the rupa totals leave it null. `level` is the site band the page + * prints beside the number (issue #114 Q5): strong at 120 % of the BPHS + * minimum or more, weak below 90 %. + */ +export const chartViewStrengthLevelSchema = z.enum(["strong", "medium", "weak"]); +export const chartViewShadbalaRowSchema = z.object({ + name: z.string().min(1), + label: z.string().min(1), + rupas: z.number(), + required: z.number().positive(), + percent: z.number(), + level: chartViewStrengthLevelSchema, + parts: z.object({ + sthana: z.number(), + dig: z.number(), + kala: z.number(), + chesta: z.number(), + naisargika: z.number(), + drik: z.number(), + }).nullable(), +}); + +/** + * Closed-list classical yogas (the consultation card's TRADITIONAL_YOGAS, + * scripts/consultation_native_layers.py). `formed` lists what the engine found + * formed, with the reductions it reported; `notFormed` is the rest of the + * closed list, folded away on the page (issue #114 Q6). + */ +export const chartViewYogaSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + planets: z.array(z.string().min(1)), + reductions: z.array(z.string().min(1)), +}); +export const chartViewYogasSchema = z.object({ + formed: z.array(chartViewYogaSchema), + notFormed: z.array(z.object({ id: z.string().min(1), label: z.string().min(1) })), +}); + +/** + * The birth moment's calendar as far as the natal packet carries it: tithi + * (`tithi_lord_analysis`) and the civil weekday. `special_lagnas` sunrise is + * not used: it is hours off for many dates (BUG-1462), so the sunrise-based + * Vedic weekday is not shown either. + */ +export const chartViewBirthCalendarSchema = z.object({ + tithi: z.string().min(1).nullable(), + weekday: z.string().min(1), +}); + +/** + * Lazy layer `ashtakavarga`: `/api/ashtakavarga` and `/api/bhava_bala`. Every + * twelve-slot array is indexed by sign (0 = Aries), as the engine returns it. + */ +export const chartViewAshtakavargaOkSchema = z.object({ + status: z.literal("ok"), + sav: z.array(z.number().int()).length(12), + bav: z.array(z.object({ + name: z.string().min(1), + label: z.string().min(1), + raw: z.array(z.number().int()).length(12), + trikona: z.array(z.number().int()).length(12), + sodhita: z.array(z.number().int()).length(12), + })), + bhavaBala: z.array(z.object({ + house: z.number().int().min(1).max(12), + sign: z.string().min(1), + total: z.number(), + adhipathi: z.number(), + dig: z.number(), + drik: z.number(), + })).length(12).nullable(), + bhavaBalaUnit: z.string().min(1), +}); + export const chartViewProfileSchema = z.object({ name: z.string().min(1), date: isoDate, @@ -299,6 +377,7 @@ export const chartViewOkSchema = z.object({ western: chartViewFailureStatusSchema.optional(), bhava: chartViewFailureStatusSchema.optional(), transit: chartViewFailureStatusSchema.optional(), + ashtakavarga: chartViewFailureStatusSchema.optional(), }).optional(), lagnaPrecheck: chartViewLagnaPrecheckSchema.optional(), status: z.literal("ok"), @@ -324,6 +403,11 @@ export const chartViewOkSchema = z.object({ western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]), bhava: z.union([chartViewBhavaOkSchema, chartViewUnavailableLayerSchema]), transit: z.union([chartViewTransitOkSchema, chartViewUnavailableLayerSchema]), + // Optional so a cached snapshot from before issue #114 still parses. + shadbala: z.array(chartViewShadbalaRowSchema).optional(), + birthCalendar: chartViewBirthCalendarSchema.nullable().optional(), + yogas: chartViewYogasSchema.optional(), + ashtakavarga: z.union([chartViewAshtakavargaOkSchema, chartViewUnavailableLayerSchema]).optional(), accountId: z.string().min(1).optional(), profileFingerprint: z.string().min(1).optional(), subjectId: z.string().min(1).optional(), @@ -359,6 +443,11 @@ export type ChartViewVargaAscendantRow = z.infer; export type NorthIndianChart = z.infer; export type ChartViewAscendantRow = z.infer; +export type ChartViewShadbalaRow = z.infer; +export type ChartViewStrengthLevel = z.infer; +export type ChartViewYogas = z.infer; +export type ChartViewBirthCalendar = z.infer; +export type ChartViewAshtakavargaOk = z.infer; export const CHART_VIEW_TABS = [ { id: "vedic", label: "星盘" }, diff --git a/frontend/src/lib/chart-view-engine.ts b/frontend/src/lib/chart-view-engine.ts index e398bd7b..cce9daa5 100644 --- a/frontend/src/lib/chart-view-engine.ts +++ b/frontend/src/lib/chart-view-engine.ts @@ -2,7 +2,7 @@ export const CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000; export const CHART_VIEW_ENGINE_CACHE_TTL_MS = 5 * 60 * 1_000; const ENGINE_CACHE_MAX_ENTRIES = 200; -export const CHART_VIEW_LAYERS = ["varga", "chara", "yogini", "western", "bhava", "transit"] as const; +export const CHART_VIEW_LAYERS = ["varga", "chara", "yogini", "western", "bhava", "transit", "ashtakavarga"] as const; export type ChartViewLayer = (typeof CHART_VIEW_LAYERS)[number]; /** @@ -59,6 +59,18 @@ export function parseChartViewLayers(raw: string | null | undefined): ChartViewL return CHART_VIEW_LAYERS.filter((id) => wanted.has(id)); } +/** + * `transitDate` query (issue #114 Q9): any calendar day, past or future, in + * years 1000–2999. Anything else is ignored and the transit is today's. + */ +export function parseTransitDate(raw: string | null | undefined): string | undefined { + if (!raw || !/^\d{4}-\d{2}-\d{2}$/.test(raw)) return undefined; + const [year, month, day] = raw.split("-").map(Number); + if (year < 1000 || year > 2999) return undefined; + const parsed = new Date(Date.UTC(year, month - 1, day)); + return parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day ? raw : undefined; +} + export function chartViewEngineCacheKey(input: { userId: string; subjectId: string; diff --git a/frontend/src/lib/chart-view-insight.ts b/frontend/src/lib/chart-view-insight.ts new file mode 100644 index 00000000..fb4761bf --- /dev/null +++ b/frontend/src/lib/chart-view-insight.ts @@ -0,0 +1,220 @@ +/** + * Chart-page reading aids that come straight from engine packets (issue #114): + * Shadbala and the closed-list classical yogas from the natal `/api/chart` + * packet, and the lazy `ashtakavarga` layer (`/api/ashtakavarga` plus + * `/api/bhava_bala`). Nothing here computes astrology; it only reads, checks + * and renames what the engine returned. + */ +import { + PLANET_ORDER, + SIGN_ORDER, + isSignName, + planetZh, +} from "./chart-view-labels.ts"; +import { panchangaNameZh } from "./panchanga-names-zh.ts"; +import type { + ChartViewAshtakavargaOk, + ChartViewBirthCalendar, + ChartViewShadbalaRow, + ChartViewStrengthLevel, + ChartViewUnavailableLayer, + ChartViewYogas, +} from "./chart-view-contract.ts"; +import { asRecord as record } from "@/lib/guards"; + +const SEVEN = PLANET_ORDER.filter((name) => name !== "Rahu" && name !== "Ketu"); + +function finite(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +/** Site band printed beside the percentage (issue #114 Q5). */ +export function shadbalaLevel(percent: number): ChartViewStrengthLevel { + if (percent >= 120) return "strong"; + if (percent >= 90) return "medium"; + return "weak"; +} + +export const STRENGTH_LEVEL_ZH: Readonly> = { + strong: "强", + medium: "中", + weak: "弱", +}; + +function shadbalaParts(row: Record): ChartViewShadbalaRow["parts"] { + const total = (value: unknown) => finite(value) ?? finite(record(value)?.total); + const parts = { + sthana: total(row.sthana_bala), + dig: total(row.dig_bala), + kala: total(row.kala_bala), + chesta: total(row.chesta_bala), + naisargika: total(row.naisargika_bala), + drik: total(row.drik_bala), + }; + if (Object.values(parts).some((value) => value === null)) return null; + return parts as NonNullable; +} + +/** + * Seven rows in PLANET_ORDER. `modules.shadbala.planets` carries the + * components; `shadbala` (the summary) carries the same totals and the BPHS + * minimum. A body missing its total or minimum is left out, never estimated. + */ +export function mapShadbala(chart: Record): ChartViewShadbalaRow[] { + const detailed = record(record(record(chart.modules)?.shadbala)?.planets) ?? {}; + const summary = record(chart.shadbala) ?? {}; + return SEVEN.flatMap((name) => { + const detail = record(detailed[name]); + const brief = record(summary[name]); + const rupas = finite(detail?.total_rupas) ?? finite(brief?.rupas); + const required = finite(detail?.min_required) ?? finite(brief?.min_required); + if (rupas === null || required === null || required <= 0) return []; + const percent = Math.round((rupas / required) * 1000) / 10; + return [{ + name, + label: planetZh(name), + rupas: Math.round(rupas * 100) / 100, + required, + percent, + level: shadbalaLevel(percent), + parts: detail ? shadbalaParts(detail) : null, + }]; + }); +} + +/** + * The consultation card's closed list (TRADITIONAL_YOGAS in + * scripts/consultation_native_layers.py), in the same order. Only names the + * engine emits are matched; nothing outside the list is shown. + */ +export const TRADITIONAL_YOGA_LIST = [ + { id: "ruchaka", label: "鲁查卡(五大人物 · 火星)", prefixes: ["ruchaka"] }, + { id: "bhadra", label: "跋陀罗(五大人物 · 水星)", prefixes: ["bhadra"] }, + { id: "hamsa", label: "哈姆萨(五大人物 · 木星)", prefixes: ["hamsa"] }, + { id: "malavya", label: "摩罗维耶(五大人物 · 金星)", prefixes: ["malavya"] }, + { id: "sasa", label: "沙沙(五大人物 · 土星)", prefixes: ["sasa", "shasha"] }, + { id: "gajakesari", label: "象狮(月亮 · 木星)", prefixes: ["gajakesari"] }, + { id: "neechabhanga", label: "落陷取消", prefixes: ["neechabhanga", "neechbhanga"] }, + { id: "budhaaditya", label: "日水同宫(Budha-Aditya)", prefixes: ["budhaaditya", "budhaditya", "suryabudha"] }, +] as const; + +function yogaToken(name: unknown): string { + return String(name ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +export function mapYogas(chart: Record): ChartViewYogas { + const rows = Array.isArray(chart.yogas) ? chart.yogas : []; + const found = new Map(); + for (const entry of rows) { + const row = record(entry); + if (!row || row.present === false || row.hit === false) continue; + const token = yogaToken(row.name); + const item = TRADITIONAL_YOGA_LIST.find((yoga) => yoga.prefixes.some((prefix) => token.startsWith(prefix))); + if (!item) continue; + const slot = found.get(item.id) ?? { planets: [], reductions: [] }; + for (const planet of Array.isArray(row.planets) ? row.planets : []) { + if (typeof planet === "string" && (PLANET_ORDER as readonly string[]).includes(planet) && !slot.planets.includes(planet)) { + slot.planets.push(planet); + } + } + for (const reduction of Array.isArray(row.reductions) ? row.reductions : []) { + const value = text(reduction); + if (value && !slot.reductions.includes(value)) slot.reductions.push(value); + } + found.set(item.id, slot); + } + return { + formed: TRADITIONAL_YOGA_LIST.flatMap((item) => { + const slot = found.get(item.id); + return slot ? [{ id: item.id, label: item.label, planets: slot.planets, reductions: slot.reductions }] : []; + }), + notFormed: TRADITIONAL_YOGA_LIST + .filter((item) => !found.has(item.id)) + .map((item) => ({ id: item.id, label: item.label })), + }; +} + +function twelve(value: unknown): number[] | null { + if (!Array.isArray(value) || value.length !== 12) return null; + const out = value.map((item) => (typeof item === "number" && Number.isInteger(item) ? item : null)); + return out.every((item) => item !== null) ? (out as number[]) : null; +} + +export function unavailableAshtakavarga(reason: string): ChartViewUnavailableLayer { + return { status: "unavailable", reason, copy: "八宫点暂时没算出来,稍后再试。" }; +} + +/** + * SAV per sign and each body's BAV row in three readings (raw, after trikona + * reduction, after both reductions) from `/api/ashtakavarga`; house strength + * from `/api/bhava_bala`. The house layer is optional: without it the page + * still has the ashtakavarga. + */ +export function mapAshtakavarga( + ashtakavarga: Record | null, + bhavaBala: Record | null, +): ChartViewAshtakavargaOk | ChartViewUnavailableLayer { + const result = record(ashtakavarga?.result); + if (!result) return unavailableAshtakavarga("ashtakavarga_missing"); + const scores = record(record(result.sav)?.scores); + if (!scores) return unavailableAshtakavarga("sav_missing"); + const sav = SIGN_ORDER.map((sign) => finite(scores[sign])); + if (sav.some((value) => value === null || !Number.isInteger(value))) return unavailableAshtakavarga("sav_missing"); + const sodhita = record(result.sodhita); + const rawRows = record(result.bav) ?? {}; + const trikonaRows = record(sodhita?.trikona_bav) ?? {}; + const sodhitaRows = record(sodhita?.sodhita_bav) ?? {}; + const bav = SEVEN.flatMap((name) => { + const raw = twelve(record(rawRows[name])?.bindus); + const trikona = twelve(trikonaRows[name]); + const reduced = twelve(sodhitaRows[name]); + return raw && trikona && reduced ? [{ name, label: planetZh(name), raw, trikona, sodhita: reduced }] : []; + }); + if (bav.length !== SEVEN.length) return unavailableAshtakavarga("bav_missing"); + + const houses = Array.isArray(record(bhavaBala?.result)?.houses) ? (record(bhavaBala?.result)?.houses as unknown[]) : []; + const mappedHouses = houses.flatMap((entry) => { + const row = record(entry); + const house = finite(row?.house); + const sign = text(row?.sign); + const total = finite(row?.total); + if (!row || house === null || !sign || !isSignName(sign) || total === null) return []; + return [{ + house, + sign, + total, + adhipathi: finite(row.adhipathi_bala) ?? 0, + dig: finite(row.dig_bala) ?? 0, + drik: finite(row.drik_bala) ?? 0, + }]; + }); + const bhava = mappedHouses.length === 12 ? mappedHouses.sort((a, b) => a.house - b.house) : null; + return { + status: "ok", + sav: sav as number[], + bav, + bhavaBala: bhava, + bhavaBalaUnit: text(record(bhavaBala?.result)?.score_unit) ?? "Virupas", + }; +} + +const WEEKDAY_ZH = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"] as const; + +/** Birth-moment calendar from the natal packet: tithi and the civil weekday. */ +export function mapBirthCalendar(chart: Record, date: string): ChartViewBirthCalendar | null { + const [year, month, day] = date.split("-").map(Number); + if (!year || !month || !day) return null; + const tithiPacket = record(chart.tithi_lord_analysis); + const tithiName = text(tithiPacket?.tithi_name); + const paksha = text(tithiPacket?.paksha); + // The name table wants the fortnight in the name ("Krishna Saptami" → 黑分初七). + const full = tithiName ? `${paksha ?? ""} ${tithiName}`.trim() : null; + return { + tithi: full ? panchangaNameZh("tithi", full) ?? full : null, + weekday: WEEKDAY_ZH[new Date(Date.UTC(year, month - 1, day)).getUTCDay()]!, + }; +} diff --git a/frontend/src/lib/chart-view-load.ts b/frontend/src/lib/chart-view-load.ts index ffcf596c..f4394e2b 100644 --- a/frontend/src/lib/chart-view-load.ts +++ b/frontend/src/lib/chart-view-load.ts @@ -44,6 +44,8 @@ export type ChartViewLoadInput = { layers?: readonly ChartViewLayer[]; accountId?: string | null; profileFingerprint?: string | null; + /** Day the transit layer is computed for (YYYY-MM-DD); `asOf` when absent. */ + transitDate?: string; /** Started beside `/api/chart`. Failure resolves to null and never fails the chart. */ lagnaPrecheck?: Promise; }; @@ -193,7 +195,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ if (result.status !== "ok") layerFailures[layer] = engineFailureReason(result.status); return enginePayload(result); }; - const transitDate = input.asOf.slice(0, 10); + const transitDate = (input.transitDate ?? input.asOf).slice(0, 10); const dashaPackets: Record<(typeof CHART_DASHA_SYSTEMS)[number]["track"], Record | null> = { chara: null, yogini: null, @@ -239,6 +241,17 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ ).then(layerPayload("transit")) : Promise.resolve(null), ]); + // Both reads take the natal planets; the house layer is optional, so only an + // ashtakavarga failure marks the layer failed. + const ashtakavarga = layers.has("ashtakavarga") + ? await Promise.all([ + callEngine(input.postEngine, "/api/ashtakavarga", followUp), + callEngine(input.postEngine, "/api/bhava_bala", followUp), + ]).then(([main, houses]) => ({ + ashtakavarga: layerPayload("ashtakavarga")(main), + bhavaBala: enginePayload(houses), + })) + : null; await dashaReady; const lagnaPrecheck = await lagnaReady; @@ -251,6 +264,8 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ western, bhava, transit, + transitDate, + ashtakavarga, profile, asOf: input.asOf, lagnaPrecheck, diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index 5dd811d1..1726fee2 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -45,6 +45,8 @@ import { type NorthIndianChart, } from "./chart-view-contract.ts"; import { asRecord as record } from "@/lib/guards"; +import { addWeightedSpan } from "./dasha-span.ts"; +import { mapAshtakavarga, mapBirthCalendar, mapShadbala, mapYogas } from "./chart-view-insight.ts"; export type ChartViewProfileInput = { name?: string; @@ -68,6 +70,10 @@ export type ChartViewEngineBundle = { western: Record | null; bhava?: Record | null; transit?: Record | null; + /** Lazy layer `ashtakavarga`: both packets, or null when it was not asked for. */ + /** Day the transit packet was computed for; `asOf` when absent. */ + transitDate?: string; + ashtakavarga?: { ashtakavarga: Record | null; bhavaBala: Record | null } | null; profile: ChartViewProfileInput; asOf: string; }; @@ -89,25 +95,6 @@ function isoDay(value: string): string { return value.slice(0, 10); } -function addWeightedSpan(startIso: string, endIso: string, index: number, weights: readonly number[]): { start: string; end: string } { - const start = Date.parse(`${startIso}T00:00:00Z`); - const end = Date.parse(`${endIso}T00:00:00Z`); - const total = weights.reduce((sum, weight) => sum + (Number.isFinite(weight) ? weight : 0), 0); - // A zero-year Chara mahadasha gives every antardasha weight 0. `??` does not - // replace 0, so total is 0 and `before / total` is NaN. Never call - // `Date#toISOString` on that — it throws RangeError and fails the whole chart. - if (!Number.isFinite(start) || !Number.isFinite(end) || !(total > 0)) { - return { start: startIso, end: startIso }; - } - const before = weights.slice(0, index).reduce((sum, weight) => sum + (Number.isFinite(weight) ? weight : 0), 0); - const weight = Number.isFinite(weights[index]) ? weights[index]! : 0; - const span = end - start; - const from = start + span * (before / total); - const to = index === weights.length - 1 ? end : start + span * ((before + weight) / total); - if (!Number.isFinite(from) || !Number.isFinite(to)) return { start: startIso, end: startIso }; - return { start: new Date(from).toISOString().slice(0, 10), end: new Date(to).toISOString().slice(0, 10) }; -} - function addYears(startIso: string, years: number): string { const start = Date.parse(`${startIso}T00:00:00Z`); return new Date(start + years * 365.25 * 86_400_000).toISOString().slice(0, 10); @@ -903,7 +890,7 @@ export function mapLagnaPrecheck(window: LagnaPrecheckWindow, payload: unknown): } export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { - const { chart, varga, chara, yogini = null, western, bhava, transit, profile, asOf, lagnaPrecheck = null } = bundle; + const { chart, varga, chara, yogini = null, western, bhava, transit, transitDate, ashtakavarga = null, profile, asOf, lagnaPrecheck = null } = bundle; const planets = asPlanetMap(chart.planets); const moon = planets.Moon ?? {}; const ascendant = record(chart.ascendant); @@ -960,7 +947,11 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { }, western: mapWestern(western, ayanamsaDisplay), bhava: mapBhava(bhava, chart), - transit: mapTransit(transit, chart, profile, asOf), + transit: mapTransit(transit, chart, profile, transitDate ?? asOf), + birthCalendar: mapBirthCalendar(chart, profile.date), + shadbala: mapShadbala(chart), + yogas: mapYogas(chart), + ...(ashtakavarga ? { ashtakavarga: mapAshtakavarga(ashtakavarga.ashtakavarga, ashtakavarga.bhavaBala) } : {}), }; return chartViewOkSchema.parse(view); } diff --git a/frontend/src/lib/chart-view-service.ts b/frontend/src/lib/chart-view-service.ts index 60ee170a..ab86bc24 100644 --- a/frontend/src/lib/chart-view-service.ts +++ b/frontend/src/lib/chart-view-service.ts @@ -115,6 +115,8 @@ export async function loadChartView(input: { now?: Date; layers?: readonly ChartViewLayer[]; subjectId?: unknown; + /** Day for the transit layer; today when absent. */ + transitDate?: string; } = {}): Promise<{ httpStatus: number; body: ChartViewResponse; @@ -216,6 +218,7 @@ export async function loadChartView(input: { asOf: now.toISOString().slice(0, 10), layers: input.layers, lagnaPrecheck, + transitDate: input.transitDate, }); return { ...assembled, diff --git a/frontend/src/lib/chat-chart-panel-state.ts b/frontend/src/lib/chat-chart-panel-state.ts index f20e4199..f94faeb4 100644 --- a/frontend/src/lib/chat-chart-panel-state.ts +++ b/frontend/src/lib/chat-chart-panel-state.ts @@ -1,14 +1,16 @@ /** * Desktop chat natal panel. localStorage, same reason as the sidebar width: * `/` is static, so a cookie read on the server would drop that. - * The phone has no panel, and hiding it must not write `open` false. + * It starts open until the reader closes it (issue #114 Q8 A); the phone + * opens the same board as a bottom sheet, which never writes `open`. */ export const CHAT_CHART_OPEN_STORAGE_KEY = "chat_chart_panel_open"; export const CHAT_CHART_WIDTH_STORAGE_KEY = "chat_chart_panel_width"; -export const CHAT_CHART_WIDTH_MIN = 280; -export const CHAT_CHART_WIDTH_MAX = 560; -export const CHAT_CHART_WIDTH_DEFAULT = 360; +// The panel carries the whole clickable board, inspector and 九曜 table now. +export const CHAT_CHART_WIDTH_MIN = 320; +export const CHAT_CHART_WIDTH_MAX = 760; +export const CHAT_CHART_WIDTH_DEFAULT = 440; export type ChatChartStorage = Pick; @@ -29,11 +31,11 @@ export function clampChatChartWidth(width: number): number { export function readStoredChatChartOpen( storage: ChatChartStorage | null = chatChartStorage(), ): boolean { - if (storage === null) return false; + if (storage === null) return true; try { - return storage.getItem(CHAT_CHART_OPEN_STORAGE_KEY) === "1"; + return storage.getItem(CHAT_CHART_OPEN_STORAGE_KEY) !== "0"; } catch { - return false; + return true; } } diff --git a/frontend/src/lib/dasha-span.ts b/frontend/src/lib/dasha-span.ts new file mode 100644 index 00000000..61f68a2d --- /dev/null +++ b/frontend/src/lib/dasha-span.ts @@ -0,0 +1,37 @@ +/** + * Proportional split of a dasha period into sub-periods (weights in years). + * Shared by the server mapper (antardashas) and the chart page's third level + * (pratyantar, issue #114 M4) so both use one formula. + */ + +import { VIMSHOTTARI_ORDER, VIMSHOTTARI_YEARS, planetZh } from "./chart-view-labels.ts"; + +export function addWeightedSpan(startIso: string, endIso: string, index: number, weights: readonly number[]): { start: string; end: string } { + const start = Date.parse(`${startIso}T00:00:00Z`); + const end = Date.parse(`${endIso}T00:00:00Z`); + const total = weights.reduce((sum, weight) => sum + (Number.isFinite(weight) ? weight : 0), 0); + // A zero-year Chara mahadasha gives every antardasha weight 0. `??` does not + // replace 0, so total is 0 and `before / total` is NaN. Never call + // `Date#toISOString` on that — it throws RangeError and fails the whole chart. + if (!Number.isFinite(start) || !Number.isFinite(end) || !(total > 0)) { + return { start: startIso, end: startIso }; + } + const before = weights.slice(0, index).reduce((sum, weight) => sum + (Number.isFinite(weight) ? weight : 0), 0); + const weight = Number.isFinite(weights[index]) ? weights[index]! : 0; + const span = end - start; + const from = start + span * (before / total); + const to = index === weights.length - 1 ? end : start + span * ((before + weight) / total); + if (!Number.isFinite(from) || !Number.isFinite(to)) return { start: startIso, end: startIso }; + return { start: new Date(from).toISOString().slice(0, 10), end: new Date(to).toISOString().slice(0, 10) }; +} + +export type DashaSpan = { lord: string; label: string; start: string; end: string }; + +/** The nine Vimshottari sub-periods of a period ruled by `lord`, starting with `lord`. */ +export function vimshottariSplit(lord: string, startIso: string, endIso: string): DashaSpan[] { + const startIndex = VIMSHOTTARI_ORDER.indexOf(lord as (typeof VIMSHOTTARI_ORDER)[number]); + if (startIndex < 0) return []; + const order = VIMSHOTTARI_ORDER.map((_, index) => VIMSHOTTARI_ORDER[(startIndex + index) % VIMSHOTTARI_ORDER.length]!); + const weights = order.map((name) => VIMSHOTTARI_YEARS[name]); + return order.map((name, index) => ({ lord: name, label: planetZh(name), ...addWeightedSpan(startIso, endIso, index, weights) })); +} diff --git a/frontend/src/stores/ui-store.ts b/frontend/src/stores/ui-store.ts index 2c5fc5a9..1d78d836 100644 --- a/frontend/src/stores/ui-store.ts +++ b/frontend/src/stores/ui-store.ts @@ -10,6 +10,11 @@ import { writeStoredChatChartOpen, writeStoredChatChartWidth, } from "@/lib/chat-chart-panel-state"; +import { + readStoredChartBoardStyle, + writeStoredChartBoardStyle, + type ChartBoardStyle, +} from "@/lib/chart-board-style"; import { readStoredSidebarWidth, SIDEBAR_WIDTH_DEFAULT, @@ -29,6 +34,14 @@ type UiState = { setChatChartOpen: (open: boolean) => void; setChatChartWidth: (width: number) => void; hydrateChatChart: () => void; + /** Phone bottom sheet with the chat's board; not persisted. */ + chatChartSheetOpen: boolean; + setChatChartSheetOpen: (open: boolean) => void; + /** North or South Indian board, one choice for the whole site (issue #114). */ + chartBoardStyle: ChartBoardStyle; + chartBoardStyleHydrated: boolean; + setChartBoardStyle: (style: ChartBoardStyle) => void; + hydrateChartBoardStyle: () => void; }; export const useUiStore = create((set) => ({ @@ -46,7 +59,7 @@ export const useUiStore = create((set) => ({ sidebarWidthHydrated: true, }); }, - chatChartOpen: false, + chatChartOpen: true, chatChartWidth: CHAT_CHART_WIDTH_DEFAULT, chatChartHydrated: false, setChatChartOpen: (open) => { @@ -65,4 +78,15 @@ export const useUiStore = create((set) => ({ chatChartHydrated: true, }); }, + chatChartSheetOpen: false, + setChatChartSheetOpen: (open) => set({ chatChartSheetOpen: open }), + chartBoardStyle: "north", + chartBoardStyleHydrated: false, + setChartBoardStyle: (style) => { + writeStoredChartBoardStyle(style); + set({ chartBoardStyle: style, chartBoardStyleHydrated: true }); + }, + hydrateChartBoardStyle: () => { + set({ chartBoardStyle: readStoredChartBoardStyle(), chartBoardStyleHydrated: true }); + }, })); diff --git a/frontend/tests/chart-board-20261027.test.tsx b/frontend/tests/chart-board-20261027.test.tsx new file mode 100644 index 00000000..45c8f260 --- /dev/null +++ b/frontend/tests/chart-board-20261027.test.tsx @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import test from "node:test"; + +import { ChartBoardSvg } from "../src/components/chart-board/chart-board-svg.tsx"; +import { ChartInspector, inspectorQuestion } from "../src/components/chart-board/chart-inspector.tsx"; +import { ChartGrahaCard } from "../src/components/chart-board/chart-side-cards.tsx"; +import { + aspectedHouses, + boardRelations, + bodyHouse, + dignityStep, + houseLord, + lordedHouses, + stepSelection, +} from "../src/lib/chart-board-model.ts"; +import { boardSource, glanceOf, natalBoard, savBand, transitBySign } from "../src/lib/chart-board-view.ts"; +import { readStoredChartBoardStyle, writeStoredChartBoardStyle } from "../src/lib/chart-board-style.ts"; +import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import type { ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { CHART_VIEW_LAYERS, parseTransitDate } from "../src/lib/chart-view-engine.ts"; +import { mapAshtakavarga, mapBirthCalendar, mapShadbala, mapYogas, shadbalaLevel } from "../src/lib/chart-view-insight.ts"; +import { vimshottariSplit } from "../src/lib/dasha-span.ts"; + +Object.assign(globalThis, { React }); + +const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8")) as { + chart: Record; + varga_full: Record; +}; +/** Real engine output (see `sourceCommand`), trimmed to the fields the page reads. */ +const insight = JSON.parse(readFileSync(new URL("./fixtures/chart-insight-golden.json", import.meta.url), "utf8")) as { + chart: Record; + chart_modules_shadbala: Record; + ashtakavarga: Record; + bhava_bala: Record; + second_case: { + yogas: unknown[]; + vimshottari_current: { antardasha: { lord: string; start: string; end: string } }; + five_levels: { pratyantar: { lord: string; total_years: number } }; + }; +}; +const insightChart = { ...insight.chart, modules: { shadbala: insight.chart_modules_shadbala } }; + +const profile = { + name: "示例", + date: "1990-06-15", + time: "12:00", + placeLabel: "北京", + latitude: 39.9042, + longitude: 116.4074, + timezoneOffset: 8, + timezoneId: "Asia/Shanghai", + ayanamsa: "raman", + birthTimeStatus: "confirmed", +}; + +async function okView(): Promise { + const result = await assembleChartView({ + userId: "user-1", + profile, + asOf: "2026-10-27", + layers: CHART_VIEW_LAYERS, + postEngine: async (path) => { + if (path === "/api/chart") return { status: "ok", payload: { ...golden.chart, ...insightChart } }; + if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full }; + if (path === "/api/ashtakavarga") return { status: "ok", payload: insight.ashtakavarga }; + if (path === "/api/bhava_bala") return { status: "ok", payload: insight.bhava_bala }; + return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + }, + }); + assert.equal(result.body.status, "ok"); + return result.body as ChartViewOk; +} + +test("shadbala rows keep the engine totals and minimum, and print a strong/medium/weak band", () => { + const rows = mapShadbala(insightChart); + assert.equal(rows.length, 7); + const sun = rows.find((row) => row.name === "Sun")!; + assert.equal(sun.required, 6.5); + assert.equal(sun.percent, Math.round((sun.rupas / 6.5) * 1000) / 10); + assert.ok(sun.parts, "the six components come from modules.shadbala"); + const mercury = rows.find((row) => row.name === "Mercury")!; + assert.equal(mercury.level, "medium", "6.94 of 7.0 rupas is 99 %"); + assert.equal(shadbalaLevel(120), "strong"); + assert.equal(shadbalaLevel(119.9), "medium"); + assert.equal(shadbalaLevel(89.9), "weak"); + assert.deepEqual(mapShadbala({}), [], "no packet, no rows; nothing is estimated"); +}); + +test("yogas come from the consultation's closed list; reductions mark a partial yoga", () => { + const none = mapYogas(insightChart); + assert.deepEqual(none.formed, [], "Amala Yoga is outside the closed list"); + assert.equal(none.notFormed.length, 8); + const second = mapYogas({ yogas: insight.second_case.yogas }); + assert.deepEqual(second.formed.map((yoga) => yoga.id), ["ruchaka", "sasa"]); + assert.ok(second.formed.every((yoga) => yoga.reductions.length > 0)); + assert.deepEqual(second.formed[0]!.planets, ["Mars"]); + assert.equal(second.notFormed.length, 6); +}); + +test("the ashtakavarga layer keeps SAV by sign, three BAV readings and house strength", () => { + const layer = mapAshtakavarga(insight.ashtakavarga, insight.bhava_bala); + assert.equal(layer.status, "ok"); + if (layer.status !== "ok") return; + assert.equal(layer.sav.reduce((sum, value) => sum + value, 0), 337); + assert.deepEqual(layer.bav.map((row) => row.raw.reduce((a, b) => a + b, 0)), [48, 49, 39, 54, 56, 52, 39]); + for (const row of layer.bav) { + row.sodhita.forEach((value, index) => assert.ok(value <= row.trikona[index]! && row.trikona[index]! <= row.raw[index]!)); + } + assert.equal(layer.bhavaBala?.length, 12); + assert.equal(mapAshtakavarga(null, null).status, "unavailable"); + const withoutHouses = mapAshtakavarga(insight.ashtakavarga, null); + assert.equal(withoutHouses.status === "ok" && withoutHouses.bhavaBala, null, "house strength is optional"); +}); + +test("the birth calendar prints the tithi in Chinese and the civil weekday", () => { + assert.deepEqual(mapBirthCalendar(insightChart, "1990-06-15"), { tithi: "黑分初七", weekday: "星期五" }); + assert.deepEqual(mapBirthCalendar({}, "1990-06-15"), { tithi: null, weekday: "星期五" }); +}); + +test("transitDate accepts any real calendar day in 1000–2999 and nothing else", () => { + assert.equal(parseTransitDate("2031-02-28"), "2031-02-28"); + assert.equal(parseTransitDate("1850-07-01"), "1850-07-01"); + assert.equal(parseTransitDate("2031-02-30"), undefined); + assert.equal(parseTransitDate("0999-01-01"), undefined); + assert.equal(parseTransitDate("2031-2-3"), undefined); + assert.equal(parseTransitDate(null), undefined); +}); + +test("a transit for another day posts that day under its own cache scope", async () => { + const calls: Array<{ path: string; body: Record; scope?: string }> = []; + const result = await assembleChartView({ + userId: "user-1", + profile, + asOf: "2026-10-27", + transitDate: "2031-02-28", + layers: ["transit"], + postEngine: async (path, body, scope) => { + calls.push({ path, body, scope }); + return { status: "ok", payload: golden.chart }; + }, + }); + const transitCall = calls.find((call) => call.scope?.startsWith("transit:")); + assert.equal(transitCall?.scope, "transit:2031-02-28"); + assert.deepEqual([transitCall?.body.year, transitCall?.body.month, transitCall?.body.day], [2031, 2, 28]); + assert.equal(result.body.status === "ok" && result.body.transit.status === "ok" ? result.body.transit.date : null, "2031-02-28"); +}); + +test("the third dasha level uses the same split as the engine's current pratyantar", () => { + const { antardasha } = insight.second_case.vimshottari_current; + const spans = vimshottariSplit(antardasha.lord, antardasha.start, antardasha.end); + assert.equal(spans.length, 9); + assert.equal(spans[0]!.lord, antardasha.lord); + assert.equal(spans[0]!.start, antardasha.start); + assert.equal(spans.at(-1)!.end, antardasha.end); + spans.slice(1).forEach((span, index) => assert.equal(span.start, spans[index]!.end)); + const engine = insight.second_case.five_levels.pratyantar; + const now = spans.find((span) => span.start <= "2026-10-27" && "2026-10-27" < span.end)!; + assert.equal(now.lord, engine.lord, "the engine computed this run on 2026-10-27"); + const days = (Date.parse(now.end) - Date.parse(now.start)) / 86_400_000; + assert.ok(Math.abs(days - engine.total_years * 365.25) <= 1.5); +}); + +test("rulership, graha drishti and relations follow the engine's rules", async () => { + const view = await okView(); + const model = natalBoard(view); + assert.equal(model.houses[0]!.sign, 4, "Leo rising"); + assert.equal(houseLord(model, 1), "sun"); + assert.deepEqual(lordedHouses(model, "saturn"), [6, 7]); + assert.deepEqual(lordedHouses(model, "rahu"), []); + const mars = bodyHouse(model, "mars")!; + assert.deepEqual(aspectedHouses(model, "mars"), [4, 7, 8].map((n) => ((mars + n - 2) % 12) + 1)); + const relations = boardRelations(model, { kind: "body", key: "jupiter" }); + assert.equal(relations.focus, bodyHouse(model, "jupiter")); + assert.ok(relations.lit.has(relations.focus!)); + relations.aspectLinks.forEach((house) => assert.ok(aspectedHouses(model, "jupiter").includes(house))); + const house = boardRelations(model, { kind: "house", house: 1 }); + assert.deepEqual(house.lordLinks, bodyHouse(model, "sun") === 1 ? [] : [bodyHouse(model, "sun")]); + assert.deepEqual(stepSelection(model, { kind: "house", house: 12 }, 1), { kind: "house", house: 1 }); + assert.deepEqual(stepSelection(model, { kind: "body", key: "sun" }, -1), { kind: "body", key: "ketu" }); +}); + +test("dignity reads the engine's status label on a nine-step scale", () => { + assert.deepEqual(dignityStep("入旺(Exalted)"), { step: 8, label: "入旺" }); + assert.deepEqual(dignityStep("落陷(Debilitated)"), { step: 0, label: "落陷" }); + assert.deepEqual(dignityStep("落陷取消(Neecha Bhanga)"), { step: 1, label: "落陷取消" }); + assert.deepEqual(dignityStep("中性(Neutral) · 逆行"), { step: 3, label: "中性" }); + assert.equal(dignityStep("—"), null); +}); + +test("SAV bands use the traditional 28 line", () => { + assert.equal(savBand(28), "strong"); + assert.equal(savBand(25), "medium"); + assert.equal(savBand(24), "weak"); +}); + +test("north and south boards draw twelve houses, every body, and lines for a selection", async () => { + const view = await okView(); + const model = natalBoard(view); + 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); + 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" }); + assert.equal((picked.match(/class="chart-board-link"/g) ?? []).length, relations.lordLinks.length); + assert.equal((picked.match(/class="chart-board-link is-aspect"/g) ?? []).length, relations.aspectLinks.length); + assert.match(picked, /chart-board-cell is-selected/); + } +}); + +test("the inspector shows the number with its band, and 问 AI only writes a question", async () => { + const view = await okView(); + const source = boardSource(view, "D1")!; + const model = natalBoard(view); + const markup = renderToStaticMarkup(React.createElement(ChartInspector, { + view, transit: null, model, source, + selection: { kind: "body", key: "sun" }, + onSelect: () => {}, onStep: () => {}, onAsk: () => {}, + })); + const sun = view.shadbala!.find((row) => row.name === "Sun")!; + assert.ok(markup.includes(`达标 ${Math.round(sun.percent)}%`)); + assert.match(markup, /chart-band is-strong/); + assert.match(markup, /问 AI:这颗星/); + assert.match(inspectorQuestion(view, model, { kind: "body", key: "sun" }), /^我的太阳在.+第\d+宫,对我意味着什么?$/); + const empty = renderToStaticMarkup(React.createElement(ChartInspector, { + view, transit: null, model, source, selection: null, onSelect: () => {}, onStep: () => {}, onAsk: () => {}, + })); + assert.match(empty, /点盘上的星或宫看详情/); + assert.doesNotMatch(empty, /正在加载|spinner/i); +}); + +test("the 九曜 table marks the selected body and carries the six-strength column", async () => { + const view = await okView(); + const markup = renderToStaticMarkup(React.createElement(ChartGrahaCard, { view, selection: { kind: "body", key: "moon" }, onSelect: () => {} })); + assert.equal((markup.match(/
六力 {}, compact: true })); + assert.doesNotMatch(compact, />六力 { + const view = await okView(); + const glance = glanceOf(view, "2026-10-27"); + const sorted = [...view.shadbala!].sort((a, b) => b.percent - a.percent); + assert.equal(glance.strongest?.name, sorted[0]!.name); + assert.equal(glance.weakest?.name, sorted.at(-1)!.name); + assert.equal(transitBySign(null).length, 0); +}); + +test("north or south is one site-wide choice, north by default", () => { + const values = new Map(); + const storage = { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); } }; + assert.equal(readStoredChartBoardStyle(storage), "north"); + writeStoredChartBoardStyle("south", storage); + assert.equal(readStoredChartBoardStyle(storage), "south"); + assert.equal(readStoredChartBoardStyle(null), "north"); +}); + +test("the board stylesheet stays off the first paint of the chat page", () => { + const frame = readFileSync(new URL("../src/components/chat-chart-frame.tsx", import.meta.url), "utf8"); + const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); + const route = readFileSync(new URL("../src/app/(app)/chart/page.tsx", import.meta.url), "utf8"); + assert.match(frame, /import\("@\/components\/chart-board\/chart-board-styles"\)/); + assert.doesNotMatch(page, /chart-board/); + assert.match(route, /import "@\/components\/chart-board\/chart-board-styles";/); +}); diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 3b6f9b36..8e339874 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -214,9 +214,15 @@ test("the four tabs render and can each be selected", async () => { assert.doesNotMatch(markup, /chart-page-eyebrow|主盘直接算|分盘按需|不消耗点数|打开即有/); assert.doesNotMatch(markup, /词条式释义|不是对你个人的判断/); assert.match(markup, />星盘基础信息大运印度盘西洋盘 { assert.doesNotMatch(markup, /正在加载|骨架|spinner/i); if (tab.id === "vedic") assert.match(markup, /D1/); if (tab.id === "basics") { - assert.match(markup, /本命/); + // 原值:基础信息页含「本命」与「D9」 + // 新值:排盘底册的行星名册含「D9」一列和「宫主流向」 + // 原因:基础信息并进「排盘底册」(issue #114 M7),行星卡改成名册表 + assert.match(markup, /行星名册/); assert.match(markup, /D9/); + assert.match(markup, /宫主流向/); } if (tab.id === "dasha") { assert.match(markup, /Vimshottari/); @@ -392,10 +402,10 @@ test("the chart page shell is visible before the natal chart arrives", () => { // 原因:2026-09-24 D1 仅授权星盘盘位骨架(BUG-1016)。 assert.match(markup, /data-skeleton="true"/); assert.match(markup, /aria-busy="true"/); - // 原值: 4 个 `disabled=""`(CHART_VIEW_TABS.length) - // 新值: 4 个 `aria-disabled="true"` - // 原因: K3 四个页签改用 Tabs。view 为空时四个都不可选,base-ui 把禁用写在 aria-disabled 上。 - assert.equal((markup.match(/aria-disabled="true"/g) ?? []).length, CHART_VIEW_TABS.length); + // 原值: 4 个 `aria-disabled="true"`(CHART_VIEW_TABS.length) + // 新值: 2 个 `aria-disabled="true"`(页头「印度盘 / 西洋盘」) + // 原因: issue #114 Q2 A 一页铺开,原四个页签只剩页头两个体系切换;深读四页签在盘到了之后才出现。 + assert.equal((markup.match(/aria-disabled="true"/g) ?? []).length, 2); assert.equal(CHART_VIEW_TABS.length, 4); assert.doesNotMatch(markup, /正在加载|InlineSpinner|spinner|打开即有/i); assert.doesNotMatch(markup, /chart-page-eyebrow/); @@ -665,7 +675,10 @@ function buttonByLabel(h: ReturnType, label return node; } -test("opening dasha requests only chara, and Yogini is a separate request", async () => { +// 原值:点「大运」页签才请求 chara,calls === ["chara"] +// 新值:「深读 · 时运流年」默认展开,出现时请求 ashtakavarga(行运表八宫点列)、chara(双轨对照)、transit(今天的行运) +// 原因:issue #114 Q2 A 一页铺开,大运页签并进时运流年;Yogini 仍是单独一次请求 +test("the time tab asks for chara with its own layers, and Yogini is a separate request", async () => { const view = await okView(); const calls: string[] = []; const h = createClientLifecycleHarness(); @@ -674,12 +687,11 @@ test("opening dasha requests only chara, and Yogini is a separate request", asyn view, onNeedLayer: (layer: string) => { calls.push(layer); }, }))); - await h.event(buttonByLabel(h, "大运")); - assert.deepEqual(calls, ["chara"]); + assert.deepEqual(calls, ["ashtakavarga", "chara", "transit"]); assert.match(h.container.text, /Chara Dasha(kn_rao 变体)/); assert.match(h.container.text, /Vimshottari/); await h.event(buttonByLabel(h, "Yogini")); - assert.deepEqual(calls, ["chara", "yogini"]); + assert.deepEqual(calls, ["ashtakavarga", "chara", "transit", "yogini"]); } finally { await h.close(); } @@ -695,14 +707,16 @@ test("a yogini failure stays in the right column, and clicking Yogini again asks layerFailures: new Map([["yogini", "engine_busy"]]), onNeedLayer: (layer: string) => { calls.push(layer); }, }))); - await h.event(buttonByLabel(h, "大运")); + // 原值:先点「大运」页签;calls 以 "chara" 开头 + // 新值:时运流年默认展开,calls 以它出现时的三层开头 + // 原因:issue #114 Q2 A,大运页签并进时运流年 await h.event(buttonByLabel(h, "Yogini")); assert.match(h.container.text, /Vimshottari/); assert.match(h.container.text, new RegExp(CHART_VIEW_COPY.busy)); assert.doesNotMatch(h.container.text, /小运未经外部软件核对。/); assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false); await h.event(buttonByLabel(h, "Yogini")); - assert.deepEqual(calls, ["chara", "yogini", "yogini"]); + assert.deepEqual(calls, ["ashtakavarga", "chara", "transit", "yogini", "yogini"]); } finally { await h.close(); } @@ -716,7 +730,7 @@ test("yogini pending reuses the orbit sentence and does not draw a skeleton", as view, pendingLayers: new Set(["yogini"]), }))); - await h.event(buttonByLabel(h, "大运")); + // 原值:先点「大运」页签;新值:不点(时运流年默认展开);原因:issue #114 Q2 A await h.event(buttonByLabel(h, "Yogini")); assert.match(h.container.text, /这一栏还没拿到/); assert.match(h.container.text, /Vimshottari/); diff --git a/frontend/tests/chat-chart-panel-state.test.ts b/frontend/tests/chat-chart-panel-state.test.ts index 0c671d6f..2adca9fc 100644 --- a/frontend/tests/chat-chart-panel-state.test.ts +++ b/frontend/tests/chat-chart-panel-state.test.ts @@ -27,7 +27,10 @@ function memoryStorage(seed: Record = {}): ChatChartStorage & { test("chat natal panel remembers open and width on this machine", () => { const storage = memoryStorage(); - assert.equal(readStoredChatChartOpen(storage), false); + // 原值:没存过 → false(默认收起) + // 新值:没存过 → true(默认打开);用户收起后记住 false + // 原因:issue #114 Q8 A,产品 2026-10-27 定「一进聊天就出现盘」 + assert.equal(readStoredChatChartOpen(storage), true); assert.equal(readStoredChatChartWidth(storage), CHAT_CHART_WIDTH_DEFAULT); writeStoredChatChartOpen(true, storage); @@ -41,7 +44,10 @@ test("chat natal panel remembers open and width on this machine", () => { assert.equal(readStoredChatChartWidth(storage), 480); }); -test("chat natal panel width stays between 280 and 560", () => { +// 原值:宽度 280~560;新值:320~760(默认 440);原因:issue #114 M8 右栏装完整盘面、检视和九曜表 +test("chat natal panel width stays between 320 and 760", () => { + assert.equal(CHAT_CHART_WIDTH_MIN, 320); + assert.equal(CHAT_CHART_WIDTH_MAX, 760); assert.equal(clampChatChartWidth(10), CHAT_CHART_WIDTH_MIN); assert.equal(clampChatChartWidth(900), CHAT_CHART_WIDTH_MAX); assert.equal(clampChatChartWidth(Number.NaN), CHAT_CHART_WIDTH_DEFAULT); @@ -51,7 +57,10 @@ test("chat natal panel width stays between 280 and 560", () => { assert.equal(readStoredChatChartWidth(memoryStorage({ [CHAT_CHART_WIDTH_STORAGE_KEY]: "nope" })), CHAT_CHART_WIDTH_DEFAULT); }); -test("the chat page loads the natal split only when the panel opens, and phones have no entry", () => { +// 原值:手机没有入口(`if (!ready || isMobile) return null`) +// 新值:手机有「看本命盘」按钮,点了才按需加载底部抽屉 chat-chart-sheet +// 原因:issue #114 Q8 后半句,产品 2026-10-27 同意「手机从底部拉起盘面」 +test("the chat page loads the natal split only when the panel opens, and phones load a bottom sheet on demand", () => { const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const frame = readFileSync(new URL("../src/components/chat-chart-frame.tsx", import.meta.url), "utf8"); const split = readFileSync(new URL("../src/components/chat-chart-split.tsx", import.meta.url), "utf8"); @@ -60,7 +69,9 @@ test("the chat page loads the natal split only when the panel opens, and phones assert.doesNotMatch(page, /react-resizable-panels|vedic-chart-svg|chat-chart-split/); assert.match(frame, /打开本命盘/); assert.match(frame, /收起本命盘/); - assert.match(frame, /if \(!ready \|\| isMobile\) return null/); + assert.match(frame, /if \(!ready\) return null/); + assert.match(frame, /看本命盘/); + assert.match(frame, /import\("@\/components\/chat-chart-sheet"\)/); assert.match(frame, /import\("@\/components\/chat-chart-split"\)/); // 原值:split 里要有 ResizablePanelGroup 和 collapsible // 新值:split 用 useDragWidth,拖到最小以下调 onOpenChange(false) 收起 diff --git a/frontend/tests/drag-width-handle-20261027.test.tsx b/frontend/tests/drag-width-handle-20261027.test.tsx index f88af04e..8f870f5b 100644 --- a/frontend/tests/drag-width-handle-20261027.test.tsx +++ b/frontend/tests/drag-width-handle-20261027.test.tsx @@ -87,7 +87,10 @@ test("dragging the chat chart edge reports widths and closes below the minimum", }); handlers.onPointerDown(pointerDown(1000)); win.fire("pointermove", 960); - win.fire("pointermove", 700); + // 原值:拖到 700(宽 660,超过旧上限 560 被夹到上限) + // 新值:拖到 500(宽 860,超过新上限 760 被夹到上限) + // 原因:issue #114 M8 右栏装完整盘面,上限 560 → 760;这一步仍然验证「超上限被夹」 + win.fire("pointermove", 500); win.fire("pointermove", 1100); win.fire("pointerup"); assert.deepEqual(widths, [400, CHAT_CHART_WIDTH_MAX]); diff --git a/frontend/tests/fixtures/chart-insight-golden.json b/frontend/tests/fixtures/chart-insight-golden.json new file mode 100644 index 00000000..e5e6e9dd --- /dev/null +++ b/frontend/tests/fixtures/chart-insight-golden.json @@ -0,0 +1,1129 @@ +{ + "sourceCommand": "python3 scripts/jyotish_api_server.py --port 5291; POST /api/chart (profile of chart-view-golden.json: 1990-06-15 12:00 Beijing, raman, mean node), then /api/ashtakavarga and /api/bhava_bala with its planets/ascendant/houses. Only the fields the chart page reads are kept.", + "chart": { + "ascendant": { + "sign": "Leo", + "sign_idx": 4, + "degree": 29.4, + "degree_in_sign": 29.4, + "lon": 149.3958, + "nakshatra": "Uttara Phalguni", + "nakshatra_pada": 1, + "nakshatra_lord": "Sun" + }, + "planets": { + "Sun": { + "sign": "Gemini", + "sign_cn": "双子座", + "degree": 1.5302, + "degree_raw": 61.53020524354914, + "degree_in_sign": 1.5302, + "degree_in_sign_raw": 1.5302052435491404, + "house": 11, + "status": "入友(Friendly Sign)", + "retrograde": false, + "speed": 0.955135, + "nakshatra": "Mrigashira", + "nakshatra_pada": 3, + "nakshatra_lord": "Mars", + "lon": 61.53020524354914, + "sign_idx": 2 + }, + "Moon": { + "sign": "Aquarius", + "sign_cn": "水瓶座", + "degree": 18.6426, + "degree_raw": 318.6426080591374, + "degree_in_sign": 18.6426, + "degree_in_sign_raw": 18.642608059137387, + "house": 7, + "status": "入友(Friendly Sign)", + "retrograde": false, + "speed": 13.245049, + "nakshatra": "Shatabhisha", + "nakshatra_pada": 4, + "nakshatra_lord": "Rahu", + "lon": 318.6426080591374, + "sign_idx": 10 + }, + "Mars": { + "sign": "Pisces", + "sign_cn": "双鱼座", + "degree": 18.5206, + "degree_raw": 348.5206134418654, + "degree_in_sign": 18.5206, + "degree_in_sign_raw": 18.520613441865407, + "house": 8, + "status": "极友(Great Friend)", + "retrograde": false, + "speed": 0.719319, + "nakshatra": "Revati", + "nakshatra_pada": 1, + "nakshatra_lord": "Mercury", + "lon": 348.5206134418654, + "sign_idx": 11 + }, + "Mercury": { + "sign": "Taurus", + "sign_cn": "金牛座", + "degree": 12.8403, + "degree_raw": 42.840285719022226, + "degree_in_sign": 12.8403, + "degree_in_sign_raw": 12.840285719022226, + "house": 10, + "status": "极友(Great Friend)", + "retrograde": false, + "speed": 1.700636, + "nakshatra": "Rohini", + "nakshatra_pada": 1, + "nakshatra_lord": "Moon", + "lon": 42.840285719022226, + "sign_idx": 1 + }, + "Jupiter": { + "sign": "Gemini", + "sign_cn": "双子座", + "degree": 23.5363, + "degree_raw": 83.53626635396247, + "degree_in_sign": 23.5363, + "degree_in_sign_raw": 23.536266353962475, + "house": 11, + "status": "中性(Neutral)", + "retrograde": false, + "speed": 0.216454, + "nakshatra": "Punarvasu", + "nakshatra_pada": 2, + "nakshatra_lord": "Jupiter", + "lon": 83.53626635396247, + "sign_idx": 2 + }, + "Venus": { + "sign": "Aries", + "sign_cn": "白羊座", + "degree": 26.1047, + "degree_raw": 26.104679286290065, + "degree_in_sign": 26.1047, + "degree_in_sign_raw": 26.104679286290065, + "house": 9, + "status": "入友(Friendly Sign)", + "retrograde": false, + "speed": 1.175679, + "nakshatra": "Bharani", + "nakshatra_pada": 4, + "nakshatra_lord": "Venus", + "lon": 26.104679286290065, + "sign_idx": 0 + }, + "Saturn": { + "sign": "Capricorn", + "sign_cn": "摩羯座", + "degree": 1.7705, + "degree_raw": 271.7704628933387, + "degree_in_sign": 1.7705, + "degree_in_sign_raw": 1.7704628933386743, + "house": 6, + "status": "入庙(Own Sign)", + "retrograde": true, + "speed": -0.058296, + "nakshatra": "Uttara Ashadha", + "nakshatra_pada": 2, + "nakshatra_lord": "Sun", + "lon": 271.7704628933387, + "sign_idx": 9 + }, + "Rahu": { + "sign": "Capricorn", + "sign_cn": "摩羯座", + "degree": 17.4346, + "degree_raw": 287.43455159569726, + "degree_in_sign": 17.4346, + "degree_in_sign_raw": 17.434551595697258, + "house": 6, + "status": "中性(Neutral)", + "retrograde": true, + "speed": -0.052968, + "nakshatra": "Shravana", + "nakshatra_pada": 3, + "nakshatra_lord": "Moon", + "lon": 287.43455159569726, + "sign_idx": 9 + }, + "Ketu": { + "sign": "Cancer", + "sign_cn": "巨蟹座", + "degree": 17.4346, + "degree_raw": 107.43455159569726, + "degree_in_sign": 17.4346, + "degree_in_sign_raw": 17.434551595697258, + "house": 12, + "status": "极敌(Great Enemy)", + "retrograde": true, + "speed": -0.052968, + "nakshatra": "Ashlesha", + "nakshatra_pada": 1, + "nakshatra_lord": "Mercury", + "lon": 107.43455159569726, + "sign_idx": 3 + } + }, + "shadbala": { + "Sun": { + "rupas": 8.21, + "min_required": 6.5, + "meets_minimum": true, + "minimum_check": "达到 BPHS 最低要求", + "level_source": "site_band", + "level": "强(网站分档)" + }, + "Moon": { + "rupas": 6.94, + "min_required": 6.0, + "meets_minimum": true, + "minimum_check": "达到 BPHS 最低要求", + "level_source": "site_band", + "level": "充足(网站分档)" + }, + "Mars": { + "rupas": 5.95, + "min_required": 5.0, + "meets_minimum": true, + "minimum_check": "达到 BPHS 最低要求", + "level_source": "site_band", + "level": "充足(网站分档)" + }, + "Mercury": { + "rupas": 6.94, + "min_required": 7.0, + "meets_minimum": false, + "minimum_check": "未达到 BPHS 最低要求", + "level_source": "site_band", + "level": "略弱(网站分档)" + }, + "Jupiter": { + "rupas": 7.54, + "min_required": 6.5, + "meets_minimum": true, + "minimum_check": "达到 BPHS 最低要求", + "level_source": "site_band", + "level": "充足(网站分档)" + }, + "Venus": { + "rupas": 7.59, + "min_required": 5.5, + "meets_minimum": true, + "minimum_check": "达到 BPHS 最低要求", + "level_source": "site_band", + "level": "强(网站分档)" + }, + "Saturn": { + "rupas": 6.94, + "min_required": 5.0, + "meets_minimum": true, + "minimum_check": "达到 BPHS 最低要求", + "level_source": "site_band", + "level": "强(网站分档)" + } + }, + "yogas": [ + { + "name": "Amala Yoga", + "planets": [], + "category": "extended" + }, + { + "name": "Amala Yoga", + "planets": [], + "desc": "Mercury在10宫—善行得声望、晚年福报", + "cat": "extended" + } + ], + "tithi_lord_analysis": { + "tithi_number": 7, + "tithi_name": "Saptami", + "paksha": "krishna" + } + }, + "chart_modules_shadbala": { + "planets": { + "Sun": { + "sthana_bala": { + "ucha_bala": 42.82, + "sapta_d1": 15.0, + "sapta_d2": 30.0, + "sapta_d3": 15.0, + "sapta_d7": 15.0, + "sapta_d9": 7.5, + "sapta_d12": 15.0, + "sapta_d30": 22.5, + "sapta_score": 120.0, + "ojayugma_bala": 30.0, + "kendra_bala": 30.0, + "drekkana_bala": 15.0, + "total": 237.82 + }, + "dig_bala": 50, + "kala_bala": { + "nathonnata": 60.0, + "paksha": 12.85, + "tribhaga": 0, + "ayana": 30, + "hora": 0.0, + "total": 102.85 + }, + "chesta_bala": 58.0, + "naisargika_bala": 60.0, + "drik_bala": -16.02, + "total_virupas": 492.65, + "total_rupas": 8.2108, + "min_required": 6.5, + "ishta_bala_pct": 126.3, + "strength_level": "强", + "ishta_phala": 49.83, + "kashta_phala": 5.86, + "rank": 1 + }, + "Moon": { + "sthana_bala": { + "ucha_bala": 35.21, + "sapta_d1": 15.0, + "sapta_d2": 30.0, + "sapta_d3": 22.5, + "sapta_d7": 22.5, + "sapta_d9": 3.75, + "sapta_d12": 22.5, + "sapta_d30": 22.5, + "sapta_score": 138.75, + "ojayugma_bala": 15.0, + "kendra_bala": 60.0, + "drekkana_bala": 0.0, + "total": 248.96 + }, + "dig_bala": 30, + "kala_bala": { + "nathonnata": 0.0, + "paksha": 17.15, + "tribhaga": 0, + "ayana": 30, + "hora": 0.0, + "total": 47.15 + }, + "chesta_bala": 34.3, + "naisargika_bala": 51.43, + "drik_bala": 4.6, + "total_virupas": 416.44, + "total_rupas": 6.9406, + "min_required": 6.0, + "ishta_bala_pct": 115.7, + "strength_level": "充足", + "ishta_phala": 34.75, + "kashta_phala": 25.24, + "rank": 5 + }, + "Mars": { + "sthana_bala": { + "ucha_bala": 43.16, + "sapta_d1": 22.5, + "sapta_d2": 22.5, + "sapta_d3": 22.5, + "sapta_d7": 15.0, + "sapta_d9": 22.5, + "sapta_d12": 15.0, + "sapta_d30": 22.5, + "sapta_score": 142.5, + "ojayugma_bala": 15.0, + "kendra_bala": 30.0, + "drekkana_bala": 0.0, + "total": 230.66 + }, + "dig_bala": 40, + "kala_bala": { + "nathonnata": 0.0, + "paksha": 12.85, + "tribhaga": 0, + "ayana": 30, + "hora": 0.0, + "total": 42.85 + }, + "chesta_bala": 35.0, + "naisargika_bala": 17.14, + "drik_bala": -8.94, + "total_virupas": 356.71, + "total_rupas": 5.9452, + "min_required": 5.0, + "ishta_bala_pct": 118.9, + "strength_level": "充足", + "ishta_phala": 38.87, + "kashta_phala": 20.52, + "rank": 7 + }, + "Mercury": { + "sthana_bala": { + "ucha_bala": 19.28, + "sapta_d1": 22.5, + "sapta_d2": 7.5, + "sapta_d3": 30.0, + "sapta_d7": 3.75, + "sapta_d9": 15.0, + "sapta_d12": 22.5, + "sapta_d30": 15.0, + "sapta_score": 116.25, + "ojayugma_bala": 15.0, + "kendra_bala": 60.0, + "drekkana_bala": 15.0, + "total": 225.53 + }, + "dig_bala": 30, + "kala_bala": { + "nathonnata": 60.0, + "paksha": 12.85, + "tribhaga": 0, + "ayana": 30, + "hora": 0.0, + "total": 102.85 + }, + "chesta_bala": 50.0, + "naisargika_bala": 25.71, + "drik_bala": -17.57, + "total_virupas": 416.52, + "total_rupas": 6.942, + "min_required": 7.0, + "ishta_bala_pct": 99.2, + "strength_level": "略弱", + "ishta_phala": 31.05, + "kashta_phala": 20.18, + "rank": 4 + }, + "Jupiter": { + "sthana_bala": { + "ucha_bala": 56.18, + "sapta_d1": 7.5, + "sapta_d2": 7.5, + "sapta_d3": 3.75, + "sapta_d7": 22.5, + "sapta_d9": 7.5, + "sapta_d12": 30.0, + "sapta_d30": 7.5, + "sapta_score": 86.25, + "ojayugma_bala": 15.0, + "kendra_bala": 30.0, + "drekkana_bala": 0.0, + "total": 187.43 + }, + "dig_bala": 40, + "kala_bala": { + "nathonnata": 60.0, + "paksha": 17.15, + "tribhaga": 45, + "ayana": 15, + "hora": 60.0, + "total": 197.15 + }, + "chesta_bala": 20.0, + "naisargika_bala": 34.29, + "drik_bala": -26.77, + "total_virupas": 452.1, + "total_rupas": 7.535, + "min_required": 6.5, + "ishta_bala_pct": 115.9, + "strength_level": "充足", + "ishta_phala": 33.52, + "kashta_phala": 12.36, + "rank": 3 + }, + "Venus": { + "sthana_bala": { + "ucha_bala": 50.3, + "sapta_d1": 15.0, + "sapta_d2": 7.5, + "sapta_d3": 15.0, + "sapta_d7": 30.0, + "sapta_d9": 15.0, + "sapta_d12": 22.5, + "sapta_d30": 30.0, + "sapta_score": 135.0, + "ojayugma_bala": 15.0, + "kendra_bala": 15.0, + "drekkana_bala": 15.0, + "total": 230.3 + }, + "dig_bala": 10, + "kala_bala": { + "nathonnata": 60.0, + "paksha": 17.15, + "tribhaga": 45, + "ayana": 15, + "hora": 0.0, + "total": 137.15 + }, + "chesta_bala": 50.0, + "naisargika_bala": 42.86, + "drik_bala": -14.77, + "total_virupas": 455.54, + "total_rupas": 7.5923, + "min_required": 5.5, + "ishta_bala_pct": 138.0, + "strength_level": "强", + "ishta_phala": 50.15, + "kashta_phala": 9.85, + "rank": 2 + }, + "Saturn": { + "sthana_bala": { + "ucha_bala": 36.08, + "sapta_d1": 30.0, + "sapta_d2": 7.5, + "sapta_d3": 30.0, + "sapta_d7": 7.5, + "sapta_d9": 30.0, + "sapta_d12": 30.0, + "sapta_d30": 22.5, + "sapta_score": 157.5, + "ojayugma_bala": 0.0, + "kendra_bala": 15.0, + "drekkana_bala": 0.0, + "total": 208.58 + }, + "dig_bala": 50, + "kala_bala": { + "nathonnata": 0.0, + "paksha": 12.85, + "tribhaga": 45, + "ayana": 15, + "hora": 0.0, + "total": 72.85 + }, + "chesta_bala": 60.0, + "naisargika_bala": 8.57, + "drik_bala": 16.33, + "total_virupas": 416.33, + "total_rupas": 6.9388, + "min_required": 5.0, + "ishta_bala_pct": 138.8, + "strength_level": "强", + "ishta_phala": 46.53, + "kashta_phala": 0.0, + "rank": 6 + } + } + }, + "ashtakavarga": { + "success": true, + "result": { + "sav": { + "scores": { + "Aries": 35, + "Taurus": 26, + "Gemini": 26, + "Cancer": 24, + "Leo": 24, + "Virgo": 28, + "Libra": 29, + "Scorpio": 28, + "Sagittarius": 28, + "Capricorn": 34, + "Aquarius": 23, + "Pisces": 32 + } + }, + "bav": { + "Sun": { + "bindus": [ + 6, + 1, + 3, + 5, + 1, + 5, + 6, + 5, + 3, + 5, + 4, + 4 + ] + }, + "Moon": { + "bindus": [ + 4, + 4, + 4, + 5, + 5, + 2, + 3, + 4, + 6, + 5, + 3, + 4 + ] + }, + "Mars": { + "bindus": [ + 5, + 2, + 2, + 3, + 3, + 4, + 5, + 4, + 2, + 3, + 1, + 5 + ] + }, + "Mercury": { + "bindus": [ + 6, + 6, + 3, + 4, + 3, + 5, + 4, + 7, + 3, + 5, + 4, + 4 + ] + }, + "Jupiter": { + "bindus": [ + 4, + 4, + 7, + 2, + 6, + 6, + 3, + 1, + 7, + 6, + 4, + 6 + ] + }, + "Venus": { + "bindus": [ + 6, + 5, + 4, + 2, + 4, + 4, + 5, + 4, + 3, + 6, + 4, + 5 + ] + }, + "Saturn": { + "bindus": [ + 4, + 4, + 3, + 3, + 2, + 2, + 3, + 3, + 4, + 4, + 3, + 4 + ] + }, + "Lagna": { + "bindus": [ + 5, + 5, + 5, + 3, + 4, + 2, + 4, + 5, + 5, + 4, + 2, + 5 + ] + } + }, + "sodhita": { + "trikona_bav": { + "Sun": [ + 5, + 0, + 0, + 1, + 0, + 4, + 3, + 1, + 2, + 4, + 1, + 0 + ], + "Moon": [ + 0, + 2, + 1, + 1, + 1, + 0, + 0, + 0, + 2, + 3, + 0, + 0 + ], + "Mars": [ + 3, + 0, + 1, + 0, + 1, + 2, + 4, + 1, + 0, + 1, + 0, + 2 + ], + "Mercury": [ + 3, + 1, + 0, + 0, + 0, + 0, + 1, + 3, + 0, + 0, + 1, + 0 + ], + "Jupiter": [ + 0, + 0, + 4, + 1, + 2, + 2, + 0, + 0, + 3, + 2, + 1, + 5 + ], + "Venus": [ + 3, + 1, + 0, + 0, + 1, + 0, + 1, + 2, + 0, + 2, + 0, + 3 + ], + "Saturn": [ + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 2, + 0, + 1 + ] + }, + "sodhita_bav": { + "Sun": [ + 5, + 0, + 0, + 1, + 0, + 4, + 3, + 0, + 2, + 4, + 1, + 0 + ], + "Moon": [ + 0, + 2, + 1, + 1, + 1, + 0, + 0, + 0, + 2, + 3, + 0, + 0 + ], + "Mars": [ + 3, + 0, + 1, + 0, + 1, + 1, + 4, + 0, + 0, + 1, + 0, + 2 + ], + "Mercury": [ + 3, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "Jupiter": [ + 0, + 0, + 4, + 1, + 2, + 0, + 0, + 0, + 0, + 2, + 1, + 5 + ], + "Venus": [ + 3, + 1, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 2, + 0, + 3 + ], + "Saturn": [ + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 2, + 0, + 1 + ] + } + } + } + }, + "bhava_bala": { + "success": true, + "result": { + "score_unit": "Virupas", + "houses": [ + { + "house": 1, + "sign": "Leo", + "adhipathi_bala": 35.0, + "dig_bala": 30.0, + "drik_bala": -17.64, + "total": 47.36, + "level": "中强" + }, + { + "house": 2, + "sign": "Virgo", + "adhipathi_bala": 40.0, + "dig_bala": 50.0, + "drik_bala": 9.79, + "total": 99.79, + "level": "极强" + }, + { + "house": 3, + "sign": "Libra", + "adhipathi_bala": 35.0, + "dig_bala": 40.0, + "drik_bala": 3.58, + "total": 78.58, + "level": "极强" + }, + { + "house": 4, + "sign": "Scorpio", + "adhipathi_bala": 45.0, + "dig_bala": 30.0, + "drik_bala": 0.37, + "total": 75.37, + "level": "极强" + }, + { + "house": 5, + "sign": "Sagittarius", + "adhipathi_bala": 35.0, + "dig_bala": 10.0, + "drik_bala": 6.89, + "total": 51.89, + "level": "强" + }, + { + "house": 6, + "sign": "Capricorn", + "adhipathi_bala": 55.0, + "dig_bala": 40.0, + "drik_bala": 1.33, + "total": 96.33, + "level": "极强" + }, + { + "house": 7, + "sign": "Aquarius", + "adhipathi_bala": 55.0, + "dig_bala": 0.0, + "drik_bala": -3.37, + "total": 51.63, + "level": "强" + }, + { + "house": 8, + "sign": "Pisces", + "adhipathi_bala": 35.0, + "dig_bala": 20.0, + "drik_bala": -2.56, + "total": 52.44, + "level": "强" + }, + { + "house": 9, + "sign": "Aries", + "adhipathi_bala": 45.0, + "dig_bala": 50.0, + "drik_bala": 3.75, + "total": 98.75, + "level": "极强" + }, + { + "house": 10, + "sign": "Taurus", + "adhipathi_bala": 35.0, + "dig_bala": 60.0, + "drik_bala": -6.87, + "total": 88.13, + "level": "极强" + }, + { + "house": 11, + "sign": "Gemini", + "adhipathi_bala": 40.0, + "dig_bala": 40.0, + "drik_bala": -8.93, + "total": 71.07, + "level": "极强" + }, + { + "house": 12, + "sign": "Cancer", + "adhipathi_bala": 40.0, + "dig_bala": 20.0, + "drik_bala": 13.29, + "total": 73.29, + "level": "极强" + } + ] + } + }, + "second_case": { + "note": "Fictional birth 1990-04-12 18:42 Hangzhou (lahiri, mean node), same local engine run: /api/chart `yogas`, and /api/dasha vimshottari current periods computed on 2026-10-27.", + "yogas": [ + { + "name": "Ruchaka Yoga", + "planets": [ + "Mars" + ], + "category": "PMC", + "reductions": [ + "与凶星同宫:土星、罗睺" + ], + "d9_dignity_modifier": null + }, + { + "name": "Sasa Yoga", + "planets": [ + "Saturn" + ], + "category": "PMC", + "reductions": [ + "与凶星同宫:火星、罗睺" + ], + "d9_dignity_modifier": null + }, + { + "name": "Kemadruma Yoga", + "planets": [ + "Moon" + ], + "category": "extended" + }, + { + "name": "Gandanta", + "planets": [], + "category": "extended" + }, + { + "name": "Kemadruma Yoga", + "planets": [ + "Moon" + ], + "desc": "Moon孤立无援—人生孤独感强、自我依赖", + "cat": "extended" + }, + { + "name": "Gandanta", + "planets": [], + "desc": "Sun在水火交界—业力节点、灵性转化", + "cat": "extended" + } + ], + "vimshottari_current": { + "mahadasha": { + "lord": "Mercury", + "years": 17, + "start": "2020-08-21", + "end": "2037-08-21" + }, + "antardasha": { + "lord": "Venus", + "years": null, + "start": "2024-01-14", + "end": "2026-11-14" + }, + "remaining_days": 18, + "remaining_months": 0.6, + "theme": "智力发展、商业期", + "keywords": [ + "商业谈判", + "智力发展", + "信息处理" + ] + }, + "five_levels": { + "mahadasha": { + "lord": "Mercury", + "years_into_period": 6.182067077344285, + "total_years": 17, + "level": "Mahadasha" + }, + "bhukti": { + "lord": "Venus", + "years_into_period": 2.782067077344285, + "total_years": 2.833333333333333, + "level": "Bhukti" + }, + "pratyantar": { + "lord": "Ketu", + "years_into_period": 0.11401152178872936, + "total_years": 0.16527777777777777, + "level": "Pratyantar" + } + }, + "current_antardashas": [ + { + "lord": "Mercury", + "years": null, + "start": "2020-08-21", + "end": "2023-01-17" + }, + { + "lord": "Ketu", + "years": null, + "start": "2023-01-17", + "end": "2024-01-14" + }, + { + "lord": "Venus", + "years": null, + "start": "2024-01-14", + "end": "2026-11-14" + }, + { + "lord": "Sun", + "years": null, + "start": "2026-11-14", + "end": "2027-09-21" + }, + { + "lord": "Moon", + "years": null, + "start": "2027-09-21", + "end": "2029-02-19" + }, + { + "lord": "Mars", + "years": null, + "start": "2029-02-19", + "end": "2030-02-16" + }, + { + "lord": "Rahu", + "years": null, + "start": "2030-02-16", + "end": "2032-09-05" + }, + { + "lord": "Jupiter", + "years": null, + "start": "2032-09-05", + "end": "2034-12-11" + }, + { + "lord": "Saturn", + "years": null, + "start": "2034-12-11", + "end": "2037-08-21" + } + ] + } +} \ No newline at end of file