From 830799fafb301f68d2bb2059437919e35f96536a Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 15 Sep 2026 16:25:31 +0800 Subject: [PATCH] feat(chart): add a read-only natal chart page Open /chart from the sidebar without billing or a model. Five tabs; Western and Qizheng stay unavailable until those endpoints land. Ephemeris nav points at /ephemeris (404 until that page ships). --- BLOCKED.md | 6 + CHANGELOG.md | 4 + docs/tasks/PROGRESS-chart-page-20260915.md | 76 + docs/tasks/README.md | 2 +- docs/testing/chart-page-20260915.md | 30 + frontend/DESIGN.md | 26 + frontend/src/app/api/chart-view/route.ts | 11 + frontend/src/app/chart/page.tsx | 19 + frontend/src/app/globals.css | 330 ++ frontend/src/components/app-sidebar.tsx | 39 + .../chart-page/chart-basics-tab.tsx | 21 + .../components/chart-page/chart-dasha-tab.tsx | 54 + .../src/components/chart-page/chart-format.ts | 9 + .../components/chart-page/chart-page-view.tsx | 85 + .../chart-page/chart-param-lines.tsx | 31 + .../chart-page/chart-qizheng-tab.tsx | 55 + .../components/chart-page/chart-vedic-tab.tsx | 76 + .../chart-page/chart-western-tab.tsx | 53 + .../chart-page/qizheng-palace-grid.tsx | 24 + .../chart-page/western-wheel-svg.tsx | 79 + frontend/src/lib/chart-view-contract.ts | 205 + frontend/src/lib/chart-view-labels.ts | 169 + frontend/src/lib/chart-view-load.ts | 124 + frontend/src/lib/chart-view-mapper.ts | 670 +++ frontend/src/lib/chart-view-service.ts | 107 + frontend/src/lib/chart-view-symbols.ts | 41 + frontend/tests/chart-page-view.test.tsx | 147 + frontend/tests/chart-view-route.test.ts | 152 + .../tests/fixtures/chart-view-golden.json | 3793 +++++++++++++++++ tests/test_api_server_security.py | 1 + 30 files changed, 6438 insertions(+), 1 deletion(-) create mode 100644 docs/tasks/PROGRESS-chart-page-20260915.md create mode 100644 docs/testing/chart-page-20260915.md create mode 100644 frontend/src/app/api/chart-view/route.ts create mode 100644 frontend/src/app/chart/page.tsx create mode 100644 frontend/src/components/chart-page/chart-basics-tab.tsx create mode 100644 frontend/src/components/chart-page/chart-dasha-tab.tsx create mode 100644 frontend/src/components/chart-page/chart-format.ts create mode 100644 frontend/src/components/chart-page/chart-page-view.tsx create mode 100644 frontend/src/components/chart-page/chart-param-lines.tsx create mode 100644 frontend/src/components/chart-page/chart-qizheng-tab.tsx create mode 100644 frontend/src/components/chart-page/chart-vedic-tab.tsx create mode 100644 frontend/src/components/chart-page/chart-western-tab.tsx create mode 100644 frontend/src/components/chart-page/qizheng-palace-grid.tsx create mode 100644 frontend/src/components/chart-page/western-wheel-svg.tsx create mode 100644 frontend/src/lib/chart-view-contract.ts create mode 100644 frontend/src/lib/chart-view-labels.ts create mode 100644 frontend/src/lib/chart-view-load.ts create mode 100644 frontend/src/lib/chart-view-mapper.ts create mode 100644 frontend/src/lib/chart-view-service.ts create mode 100644 frontend/src/lib/chart-view-symbols.ts create mode 100644 frontend/tests/chart-page-view.test.tsx create mode 100644 frontend/tests/chart-view-route.test.ts create mode 100644 frontend/tests/fixtures/chart-view-golden.json diff --git a/BLOCKED.md b/BLOCKED.md index 8ed32e41..335d9c06 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -7,6 +7,12 @@ - **上游 Git 仓不再从 GitHub 拉。** 本机目录 `G:\Ferti\yinduzhanxing-codex-add-birth-time-rectification-skill` 即任务书 `/workspace/yinduzhanxing`(分支 `codex/add-birth-time-rectification-skill` / `a911c890`)。任务书列出的 `vendor/stem-branch/{LICENSE,README.md,package.json,dist/cli.cjs}` 已按内容拷贝到本 worktree 同名路径,运行时只读仓库内相对路径。`scripts/qizheng_chart_engine.py` 以上游 168 行适配器为起点,按任务 2 改了派别参数、boundary、坐标系和错误路径。CLI 不暴露 `ketuMode`/`siderealMode`,默认走 `cli.cjs`,覆盖派别时用同包 `dist/index.cjs`。 - **本机无仓库 `.venv` 目录:** 主仓 `.venv` 是指向 `/workspace/Jyotisha/.venv` 的 git symlink,Windows 检出为 25 字节指针文件。测试与预检使用 Anaconda Python 3.11.7(已有 `pytest` 与 `pyswisseph`)。`timezonefinder` 仍缺,与既有 BLOCKED 条目一致。 +## 只读星盘页(2026-09-15,分支 `codex/chart-page-20260915`) + +- **浏览器级走查未做:** 执行环境无登录态、无 Chrome。清单在 `docs/testing/chart-page-20260915.md`,不得标成通过。 +- **无 Docker:** 未跑 `npm run test:db`。本单未动表。 +- **`next build`:** 本 worktree 的 `frontend/node_modules` 是指向主仓的 junction。Turbopack 可能报 `Symlink ... points out of the filesystem root`。`/` 是否仍 Static 用 `next build --webpack` 核,不得把未跑的构建写成通过。 + ## ~~快速门 timezone 推断依赖~~(2026-09-15 解除:验收机已装 `timezonefinder 9.0.0`) - **`run_quality_gate.py --profile quick` 必红一条,不是 BUG-693/694 的回归。** 验收机在 `1d2aeffa` 上 `739 passed, 1 failed`;同一条在修复前的 `039b0a26` 上同样红。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 702e2f4a..772d1ee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-15 — 侧栏可以打开星盘,不必先花点数等模型 + +登录后侧栏「新建对话」下面多了「星盘」和「星历」。星盘页打开即有:印度盘(D1–D30)、基础信息、Vimshottari 与 Chara 两条大运,不扣点、不调模型。西洋盘和七政四余在计算端点可用时点亮;拿不到时那一栏说明原因,整页照常。星历入口会进 `/ephemeris`,该页尚未合入时打开是 404,这是预期。Skill 版本未 bump。 + ## 2026-09-15 — 只读接口:七政四余、西洋盘、换座与停滞 计算侧新增三个只读接口:七政四余本命、回归黄道本命、指定区间的换座与停滞。不扣点、不调模型。七政宿度自角宿初度起算,与恒星黄经、回归黄道不是同一套,界面不得互相换算。庙旺字段未闭合,不会写进结论。本单不提供四柱。Skill 版本未 bump。 diff --git a/docs/tasks/PROGRESS-chart-page-20260915.md b/docs/tasks/PROGRESS-chart-page-20260915.md new file mode 100644 index 00000000..28509d7e --- /dev/null +++ b/docs/tasks/PROGRESS-chart-page-20260915.md @@ -0,0 +1,76 @@ +# PROGRESS · 只读星盘页(2026-09-15) + +工作树:`.worktrees/chart-page-20260915` +分支:`codex/chart-page-20260915` +任务书基线:`2d7698ea`;开工时 `origin/staging` = **`4cacea8e`**(其后纯文档提交若干,代码基线以当时 HEAD 为准)。 +本机 Windows。未新建 worktree。 + +Skill **未 bump**。未开 BUG 号(704–706 预留未用)。未改 `scripts/**`、`deploy/**`、`vendor/**`、`frontend/src/app/page.tsx`、`vedic-chart-svg.tsx`、`frontend/src/app/ephemeris/**`。未做 `TASK-api-server-decomposition-20260916`。 + +## 开工基线 + +| 项 | 数字 | +| --- | --- | +| `frontend/src/app/page.tsx` | **1951** 行(PowerShell `(Get-Content).Count`) | +| `tsc --noEmit` | 0 错 | +| 本单新增测试 | 见下;全量 `npm test` 收尾时再记 | + +`frontend/node_modules` 用 junction 指向主仓 `G:\Ferti\Jyotisha\frontend\node_modules`(本 worktree 无独立安装)。 + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| 1 BFF `/api/chart-view` | 完成 | GET;出生资料只读服务端;不 import billing / mastra | +| 2 五个 Tab | 完成 | 前三个印度 Tab 用现有 `/api/chart` + `/api/varga_full` + `/api/dasha/chara`;西洋 / 七政未上线时 `unavailable` | +| 3 侧栏两个入口 | 完成 | 星盘 `/chart`、星历 `/ephemeris`(对方未合入前 404 是预期) | + +## 实现要点 + +- 独立 route `frontend/src/app/chart/page.tsx`(`force-dynamic`)。服务端 `loadChartView()` 算完再揭幕,页面组件里没有 spinner / 骨架 / 「正在加载」。 +- 盘面从 `@/components/personal-report/vedic-chart-svg` 引入,导出签名零改动。 +- 上游:先 `/api/chart`,再并行 chara / varga_full / western / qizheng。chara 与 varga 带上 `planets`、`ascendant`、`houses`。Vimshottari 大运取 `/api/chart` 的 `dasha.periods`,不单独打 `/api/dasha`。 +- 西洋 / 七政失败或 404 → 该字段 `unavailable`,其余 Tab 照常。 +- 分运:Vimshottari 按标准年限比例从大运边界推,界面注明「不是引擎单独返回的字段」。Chara 名称写 **Chara Dasha(kn_rao 变体)**。 +- 中宫参数卡:≥768 六行;≤767 中宫两行(岁差、交点),完整卡在盘下。切点跟壳层。 +- 侧栏新入口复用 `.report-nav-button`,当前项白玻璃 + 2px 深棕标记。 + +## 既有断言改动 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `tests/test_api_server_security.py` `app_routes` | 不含 `chart` | 加入 `'chart'`(排序在 `home` 前) | `_scan_app_routes` 会扫到新的 `frontend/src/app/chart/page.tsx` | + +未改其他既有断言。 + +## Golden + +`frontend/tests/fixtures/chart-view-golden.json` 来自本机 Anaconda `swisseph 2.10.03` 实跑:`domain_calculation_service.compute_chart` + `compute_vimshottari_timeline`、`DivisionalChartsCalculator`、`jaimini.calc_chara_dasha_with_antardasha`(去掉未使用的 `pratyantar_dashas`)、`western_chart_engine.build_tropical_natal_chart`。虚构出生 `1990-06-15 12:00` 北京 39.9042N 116.4074E tz+8 Raman mean node(与既有 `report-chart-blocks-golden` 同一例)。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `npx tsx --test tests/chart-view-route.test.ts tests/chart-page-view.test.tsx` | **14 pass / 0 fail**(本单新增) | +| 上列 + `class-name-definition` / `sidebar-contract` / `dark-theme-contract` / `personal-report-theme-contract` | **69 pass / 0 fail** | +| `./node_modules/.bin/tsc --noEmit` | **0 错** | +| `npm run lint` | **0 error**(全仓既有 warning;本单新文件 0 warning) | +| `page.tsx` | 仍 1951 行,未改 | +| `npx next build --webpack` | compile + TypeScript 过;Collecting page data 死在既有 `SkillPackageRegistryError`(`jyotish-birth-time-rectification@10.0.26` SHA 对不上),与本单无关。未能从本机构建表确认 `/` 的 `○ Static`。 | +| `npm test` 全量 | `# tests 3067 / # pass 2909 / # fail 144 / # skipped 14`。失败集中在无 Docker 的 database-*、部署/Caddy 的 CRLF 正则、Skill SHA、Windows 下 `G:\\G:\\` 双前缀读文件。本机不是 BLOCKED 里那台 Linux 验收机(那边记 3214 / 27 fail)。本单新增 14 条,未弱化既有断言。 | + +Windows 上改 `globals.css` 时曾被写成 CRLF,暗色主题合同会整文件红;已把本单改过的文件转回 LF。不要再让 CSS 带 CR。 + +## 合入顺序 + +规定:**qizheng → 本单 → ephemeris**。 + +2026-09-15 收尾时 `git fetch`:`origin/staging` = `3e58e85f`(比开工 `4cacea8e` 多 1 个纯文档提交)。**qizheng 尚未合入**(staging 上没有 `/api/qizheng` / `/api/western` 端点提交)。按任务书**停在待推**,不抢推 staging。 + +星历入口现在指向尚未存在的 `/ephemeris`,对方合入前 404 是预期。 + +## 环境缺口 + +- 无登录态、无 Chrome:浏览器走查见 `docs/testing/chart-page-20260915.md`,不得写成通过。 +- 无 Docker:未跑 `npm run test:db`(本单未动表)。 +- worktree `node_modules` 是指向主仓的 junction;Turbopack `next build` 可能报 symlink 出文件系统。`/` Static 用 webpack 构建核。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index c0896d8a..401a791a 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -223,7 +223,7 @@ | `TASK-rectification-house-lord-gochara-research-20260913.md` | `PROGRESS-rectification-house-lord-gochara-research-20260913.md` | 研究单:宫主触发与木星/土星过运(合冲本命宫主、罗睺紧密合、年精度、用于 block 选上升)四种放宽,20 例公开 AA 离线量 block 层与 minute 层两组指标;引擎里已有宫主/功能吉凶/受控过运,只量缺的四条 | 待验收(无收益,关闭;不立实现单) | `codex/rectification-house-lord-gochara-research-20260913` | | `TASK-qizheng-native-chart-20260915.md` | `PROGRESS-qizheng-native-chart-20260915.md` | **后端单(独占 `scripts/jyotish_api_server.py`)**:vendored `stem-branch` 0.8.0(Apache-2.0)归档 + API 镜像 Node runtime + 七政适配器(计都派别与宿度坐标系参数化、boundary 按实测重写)+ 三个只读端点 `/api/qizheng`、`/api/western`、`/api/ephemeris_events`。实证三条:四柱时柱按 UTC 算(BUG-700,本轮不修不调用)、`ketuMode` 写死未暴露(BUG-701)、boundary 把空神煞与未闭合庙旺说成已生成(BUG-702)。BUG 段 700–703 | 已推 `origin/staging` `e776cf9d` | `codex/qizheng-native-chart-20260915` | -| `TASK-chart-page-20260915.md` | `PROGRESS-chart-page-20260915.md` | **前端单(独占 `app-sidebar.tsx`,同时加星盘与星历两个入口)**:P0 只读星盘页,五个 Tab(星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余),中宫排盘参数卡,三套坐标系各自标注且禁止互相换算。不扣点不调模型不出 spinner;不碰 `page.tsx`(1951/2000);**不搬 `vedic-chart-svg.tsx`**(rectification-board 也在用)。BUG 段 704–706 | 待领取 | — | +| `TASK-chart-page-20260915.md` | `PROGRESS-chart-page-20260915.md` | **前端单(独占 `app-sidebar.tsx`,同时加星盘与星历两个入口)**:P0 只读星盘页,五个 Tab(星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余),中宫排盘参数卡,三套坐标系各自标注且禁止互相换算。不扣点不调模型不出 spinner;不碰 `page.tsx`(1951/2000);**不搬 `vedic-chart-svg.tsx`**(rectification-board 也在用)。BUG 段 704–706 | 待验收 | `codex/chart-page-20260915` | | `TASK-ephemeris-page-20260915.md` | `PROGRESS-ephemeris-page-20260915.md` | **前端单**:P1 星历页,今日五要素 + 当日行运(相对本命宫位)+ 未来九十天换座与停滞,底部「带这天去提问」出口。页面不得出现任何运势判断。含实证缺陷:panchanga 写死 Lahiri 与账户 Raman 分裂(BUG-707,只标注不修)。侧边栏入口由 chart-page 单交付。BUG 段 707–709 | 待领取 | — | | `TASK-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **重构单(串行在 qizheng 单之后)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是全仓 3 处靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → 重新冻结行数 baseline(余量 300→50)。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,314 → 约 9,230 行。BUG 段 710+ | 待领取 | — | diff --git a/docs/testing/chart-page-20260915.md b/docs/testing/chart-page-20260915.md new file mode 100644 index 00000000..25ed9df4 --- /dev/null +++ b/docs/testing/chart-page-20260915.md @@ -0,0 +1,30 @@ +# 真人验收 · 只读星盘页(2026-09-15) + +执行环境无登录态、无 Chrome。下列条目留给有真实账号的人在 staging 走查。本单自动化不替代这些。 + +## 未登录 + +1. 退出登录后直接打开 `/chart`,应跳到 `/login`,不要停在空白页或报错页。 + +## 侧栏三态 + +2. 桌面展开(288px):「新建对话」下面是「星盘」「星历」,再是「我的报告」。既有入口文案不变。 +3. 平板折叠(64px):星盘、星历仍是图标,悬停有「星盘」「星历」提示。 +4. 手机抽屉:两个入口左对齐、可点;点「星盘」进 `/chart` 后抽屉关上。 +5. 点「星历」进 `/ephemeris`。若星历页尚未合入,404 是预期,记一笔即可。 + +## 五个 Tab + +6. 登录且出生资料齐全:打开星盘没有 spinner、骨架或「正在加载」,揭幕就是完整页。 +7. 眉标是「直接计算 · 打开即有 · 不消耗点数」。点数余额不应因打开此页而变。 +8. Tab 顺序:星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余。都能点。 +9. 星盘 Tab:D1–D30 chip 可换;盘面是北印菱形盘;右侧有行星表(星体 / 星座与度数 / 宫位 / 状态)。 +10. 桌面中宫是六行参数;把窗口收到手机宽度(≤767)后,中宫只剩岁差和交点两行,完整参数出现在盘下方。 +11. 基础信息:每颗星一张卡,含 D1、星宿与足、D9;顶部有「词条式释义,不是对你个人的判断」;没有运势句。 +12. 大运:Vimshottari 与 Chara Dasha(kn_rao 变体)并列,当前段高亮。底部有「只有两条同时指向同一段时间才算证据」。不要出现 Narayana 这个名字。 +13. 西洋盘:若端点未上线,看到静态说明(回归黄道、不能换算),整页其余 Tab 仍可用。 +14. 七政四余:若端点未上线,静态说明里必须有三条——宿度自角宿起算、计都派别、庙旺未闭合。 + +## 资料不全 + +15. 没有出生日期/时间/地点时,页面用人话提示去填星盘资料,不是 500。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 19e1aed0..8afee6e1 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -576,3 +576,29 @@ Admin 的 antd `` 是独立设计系统,不在此表。 禁止再引入 900 / 720 / 760 / 639 / 620 / 430 这类与壳层或彼此错位的切点。新切点必须先改白名单和 `viewport-breakpoint-contract.test.ts`。 +## 15. 只读星盘页 + +独立 route `/chart`,不进 `page.tsx`。打开即有,不扣点、不调模型、不出现 spinner / 骨架 / 「正在加载」。盘面从现有路径复用 `VedicChartSvg`,不搬家。 + +### 结构 + +页眉:眉标「直接计算 · 打开即有 · 不消耗点数」+ 标题「星盘」+ 出生资料一行。五个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余**。分盘 chip(D1–D30)只出现在印度「星盘」Tab 内部,不得把西洋盘或七政四余做成第 13 个 chip。 + +侧栏在「新建对话」之后、「我的报告」之前加「星盘」「星历」两个入口;折叠轨(64px)仍用既有 `report-nav-button` 图标行,当前项用白玻璃面 + 2px 深棕标记,不新造选中态。星历入口指向 `/ephemeris`(由另一单交付,合入前 404 是预期)。 + +### 中宫排盘参数卡 + +北印盘几何中心叠一张参数卡。桌面(≥768)六行:钟表时间与校正状态、出生地与经纬、岁差、交点模式、上升与月亮星宿、当前大运分运与引擎名。手机(≤767)中宫只留岁差与交点两行;完整六行改到盘下方一张卡。切点跟壳层,不另造 390。 + +盘内 SVG 文字用 CSS 提到 16px viewBox 单位,使约 318px 盘宽下实际像素不低于 12px。Tab、分盘 chip、返回均 44px。 + +### 三套坐标系 + +每一套必须在自己的 Tab 里写明自己是什么,并显式点名不能互相换算、也不能叠成「双重印证」: + +- 印度盘:恒星黄道,岁差用资料里选的那一套。 +- 西洋盘:回归黄道,宫位 Placidus;右栏第一张是边界卡。端点未上线时 Tab 仍在,渲染静态说明,整页不失败。 +- 七政四余:宿度自角宿初度起算。必须同时出现计都派别与庙旺未闭合。端点未上线时同样只渲染静态说明。 + +「传统象征」是词条式释义,带边界句「不是对你个人的判断」,不得写成运势。大运 Tab 底部固定:「只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。」Chara 的正式名称是 **Chara Dasha(kn_rao 变体)**,不要写成 Narayana。 + diff --git a/frontend/src/app/api/chart-view/route.ts b/frontend/src/app/api/chart-view/route.ts new file mode 100644 index 00000000..51a9da41 --- /dev/null +++ b/frontend/src/app/api/chart-view/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; + +import { loadChartView } from "@/lib/chart-view-service"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +export async function GET() { + const result = await loadChartView(); + return NextResponse.json(result.body, { status: result.httpStatus }); +} diff --git a/frontend/src/app/chart/page.tsx b/frontend/src/app/chart/page.tsx new file mode 100644 index 00000000..4054051c --- /dev/null +++ b/frontend/src/app/chart/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { ChartPageView } from "@/components/chart-page/chart-page-view"; +import { loadChartView } from "@/lib/chart-view-service"; +import "../site-styles"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "星盘 · Jyotisha", + robots: { index: false, follow: false }, +}; + +export default async function ChartPage() { + const result = await loadChartView(); + if (result.httpStatus === 401) redirect("/login"); + return ; +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d5abe4e2..c7d49cf9 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -690,6 +690,22 @@ button:disabled { cursor: default; opacity: .45; } .new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; } .report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; } .report-nav-button:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } +.report-nav-button[data-active="true"] { + position: relative; + background: var(--sidebar-accent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent); + color: var(--sidebar-accent-foreground); +} +.report-nav-button[data-active="true"]::before { + position: absolute; + content: ""; + top: var(--space-3); + bottom: var(--space-3); + left: 0; + width: 2px; + border-radius: 3px; + background: var(--sidebar-ring); +} /* 新建对话 is the sidebar's single high-signal action. DESIGN.md keeps the action color scarce, so it stays tinted text on a transparent row rather than a filled block — no page-scale color surface. */ @@ -4521,6 +4537,320 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .rectification-message-wrap { display: grid; gap: var(--space-1); } +/* Read-only natal chart page. Independent route; does not grow page.tsx. */ +.chart-page-shell { + width: 100%; + height: 100%; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + padding: 0 var(--space-6) var(--space-16); + background: var(--color-canvas-soft); + color: var(--color-ink); +} +.chart-page-topbar, +.chart-page-hero, +.chart-page-tabs, +.chart-page-panel { + width: min(1100px, 100%); + margin-inline: auto; +} +.chart-page-topbar { + min-height: 64px; + display: flex; + align-items: center; +} +.chart-page-back { + min-height: 44px; + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); + font-weight: 500; + text-decoration: none; +} +.chart-page-back:hover { border-color: var(--color-border-strong); color: var(--color-ink); } +.chart-page-back:focus-visible, +.chart-page-tab:focus-visible, +.chart-page-chip:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} +.chart-page-back svg { width: 16px; height: 16px; } +.chart-page-hero { padding: var(--space-8) 0 var(--space-6); } +.chart-page-eyebrow { + margin: 0; + color: var(--color-ink-tertiary); + font-size: var(--type-overline); + font-weight: 500; + letter-spacing: 1.5px; +} +.chart-page-hero h1 { + margin: var(--space-3) 0 0; + color: var(--color-ink); + font-family: var(--font-display); + font-size: var(--type-display-lg); + font-weight: 400; + letter-spacing: -1px; + line-height: 1.1; + text-wrap: balance; +} +.chart-page-hero > p:not(.chart-page-eyebrow):not(.chart-page-birthline) { + max-width: 40em; + margin: var(--space-4) 0 0; + color: var(--color-ink-secondary); + font-size: var(--type-body-md); + line-height: 1.65; + text-wrap: pretty; +} +.chart-page-birthline { + margin: var(--space-3) 0 0; + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); +} +.chart-page-tabs { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-6); +} +.chart-page-tab, +.chart-page-chip { + min-height: 44px; + min-width: 44px; + padding: 0 var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-ink-secondary); + cursor: pointer; + font-size: var(--type-body-sm); + font-weight: 500; +} +.chart-page-tab.is-current, +.chart-page-chip.is-current { + background: var(--color-selected); + border-color: var(--color-border-strong); + box-shadow: inset 2px 0 0 var(--color-focus); + color: var(--color-ink); +} +.chart-page-vedic, +.chart-page-basics, +.chart-page-dasha, +.chart-page-western, +.chart-page-qizheng, +.chart-page-western-side, +.chart-page-center-compact { + min-width: 0; +} +.chart-page-varga-chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-4); +} +.chart-page-vedic-stage { + position: relative; + background: var(--color-canvas); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-3); +} +.chart-page-vedic-stage .personal-report-chart-svg { margin-top: 0; } +.chart-page-vedic-stage .personal-report-chart-svg text { font-size: 16px; } +.chart-page-center-card { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: min(42%, 168px); + padding: var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-canvas) 92%, transparent); + color: var(--color-ink-secondary); + font-size: var(--type-overline); + font-weight: 500; + letter-spacing: 0; + line-height: 1.35; + text-align: center; +} +.chart-page-center-card p { margin: 0; } +.chart-page-center-extra { display: none; } +.chart-page-params-below { + display: none; + margin-top: var(--space-4); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas); + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); + line-height: 1.55; +} +.chart-page-params-below p { margin: 0 0 var(--space-2); } +.chart-page-planet-table-wrap { overflow-x: auto; } +.chart-page-planet-table { + width: 100%; + border-collapse: collapse; + background: var(--color-canvas); + font-size: var(--type-body-sm); +} +.chart-page-planet-table caption { + caption-side: top; + padding: 0 0 var(--space-3); + color: var(--color-ink-secondary); + font-size: var(--type-caption); + text-align: left; +} +.chart-page-planet-table th, +.chart-page-planet-table td { + padding: var(--space-3); + border-bottom: 1px solid var(--color-border); + text-align: left; +} +.chart-page-planet-table thead th { + background: var(--color-canvas-muted); + color: var(--color-ink-secondary); + font-size: var(--type-caption); + font-weight: 500; +} +.chart-page-boundary { + margin: var(--space-4) 0 0; + color: var(--color-ink-tertiary); + font-size: var(--type-body-sm); + line-height: 1.55; + text-wrap: pretty; +} +.chart-page-note { + margin: var(--space-3) 0; + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); +} +.chart-page-basics-grid, +.chart-page-dasha-columns, +.chart-page-western-layout, +.chart-page-vedic-layout { + display: grid; + gap: var(--space-4); +} +.chart-page-planet-card, +.chart-page-dasha-track, +.chart-page-unavailable { + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas); +} +.chart-page-planet-card h3, +.chart-page-dasha-track h3, +.chart-page-western-side h3 { + margin: 0 0 var(--space-2); + font-family: var(--font-display); + font-size: var(--type-title-md); + font-weight: 400; +} +.chart-page-planet-card p { margin: 0 0 var(--space-2); font-size: var(--type-body-sm); } +.chart-page-symbol { color: var(--color-ink-tertiary); } +.chart-page-dasha-list { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--space-2); } +.chart-page-dasha-period { + padding: var(--space-3); + border: 1px solid transparent; + border-radius: var(--radius-sm); +} +.chart-page-dasha-period.is-current { + background: var(--color-selected); + border-color: var(--color-border-strong); + box-shadow: inset 2px 0 0 var(--color-focus); +} +.chart-page-dasha-period p { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin: 0; + font-size: var(--type-body-sm); +} +.chart-page-dasha-period ul { + margin: var(--space-2) 0 0; + padding: 0; + list-style: none; + color: var(--color-ink-secondary); + font-size: var(--type-caption); +} +.chart-page-dasha-period ul li.is-current { color: var(--color-ink); font-weight: 500; } +.chart-page-western-svg { display: block; width: 100%; height: auto; color: var(--color-ink); } +.chart-page-western-ring, +.chart-page-western-spoke, +.chart-page-western-house { + fill: none; + stroke: var(--color-border-strong); + stroke-width: 1.25; +} +.chart-page-western-core { fill: var(--color-canvas-muted); stroke: var(--color-border-strong); } +.chart-page-western-svg text { fill: currentColor; font-size: 16px; } +.chart-page-western-house-label, +.chart-page-western-asc { fill: var(--color-ink-tertiary); } +.chart-page-qizheng-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-2); + margin: var(--space-4) 0; +} +.chart-page-qizheng-cell { + min-height: 88px; + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + font-size: var(--type-body-sm); +} +.chart-page-qizheng-cell strong { display: block; font-size: var(--type-title-sm); font-weight: 500; } +.chart-page-qizheng-cell span, +.chart-page-qizheng-cell small, +.chart-page-qizheng-cell p { display: block; margin: var(--space-1) 0 0; color: var(--color-ink-secondary); } +.chart-page-qizheng-cell small { font-size: var(--type-caption); } +.chart-page-qizheng-hole { min-height: 0; } +.chart-page-qizheng-rules { + margin: var(--space-3) 0; + padding-left: 1.2em; + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); +} +.chart-page-unavailable > p { margin: 0 0 var(--space-3); font-size: var(--type-body-md); } + +@media (max-width: 767px) { + .chart-page-hero h1 { font-size: var(--type-display-md); } + .chart-page-center-compact { display: block; } + .chart-page-center-extra { display: none; } + .chart-page-params-below { display: block; } + .chart-page-boundary-desktop { display: none; } +} + +@media (min-width: 768px) { + .chart-page-center-compact { display: none; } + .chart-page-center-extra { display: block; } + .chart-page-params-below { display: none; } + .chart-page-basics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (min-width: 1024px) { + .chart-page-vedic-layout, + .chart-page-dasha-columns, + .chart-page-western-layout { + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: var(--space-6); + align-items: start; + } + .chart-page-basics-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} + @media (prefers-reduced-motion: reduce) { .agent-activity-status__text { animation: none; diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 7cb2c9ce..3118201c 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -2,11 +2,13 @@ import { Menu } from "@base-ui/react/menu"; import { + CalendarDays, ChevronDown, ChevronRight, Clock3, FileText, LogOut, + Orbit, Settings, WalletCards, MessageSquareText, @@ -16,6 +18,7 @@ import { UserRound, Users, } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; import { useEffect, useRef } from "react"; import type { Ref } from "react"; import { @@ -110,6 +113,8 @@ export function AppSidebar({ onOpenLogout, }: AppSidebarProps) { const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar(); + const router = useRouter(); + const pathname = usePathname(); const firstSessionRef = useRef(null); const historyHeadingRef = useRef(null); const loadMoreRef = useRef(null); @@ -149,6 +154,16 @@ export function AppSidebar({ if (isMobile) setOpenMobile(false); } + function handleOpenChart() { + router.push("/chart"); + if (isMobile) setOpenMobile(false); + } + + function handleOpenEphemeris() { + router.push("/ephemeris"); + if (isMobile) setOpenMobile(false); + } + function handleExpandHistory() { setOpen(true); window.requestAnimationFrame(() => { @@ -202,6 +217,30 @@ export function AppSidebar({ {showExpandedContent ? {creatingSession ? "正在创建" : "新建对话"} : null} + + + + + + + + +

{view.vedic.symbolBoundary}

+
+ {view.vedic.planets.map((planet) => ( +
+

{planet.label}

+

本命 {planet.signLabel} {formatDegree(planet.degreeInSign)} · 第{planet.house}宫

+

{planet.nakshatra} 第{planet.pada}足 · 星宿主 {planet.nakshatraLord}

+

D9 {planet.d9SignLabel ?? "未返回"}

+ {planet.symbol ?

{planet.symbol}

: null} +
+ ))} +
+ + ); +} diff --git a/frontend/src/components/chart-page/chart-dasha-tab.tsx b/frontend/src/components/chart-page/chart-dasha-tab.tsx new file mode 100644 index 00000000..4b27418f --- /dev/null +++ b/frontend/src/components/chart-page/chart-dasha-tab.tsx @@ -0,0 +1,54 @@ +import type { ChartViewOk } from "@/lib/chart-view-contract"; + +function Track({ + title, + method, + currentLabel, + antardashaNote, + periods, +}: ChartViewOk["dasha"]["vimshottari"]) { + return ( +
+
+

{title}

+

{method}

+

当前 {currentLabel}

+
+
    + {periods.map((period) => ( +
  1. +

    + {period.label} + {period.start} – {period.end} +

    + {period.antardashas.length > 0 ? ( +
      + {period.antardashas.map((antar) => ( +
    • + {antar.label} · {antar.start} – {antar.end} +
    • + ))} +
    + ) : null} +
  2. + ))} +
+ {antardashaNote ?

{antardashaNote}

: null} +
+ ); +} + +export function ChartDashaTab({ view }: { view: ChartViewOk }) { + return ( +
+
+ + +
+

{view.dasha.dualTrackNote}

+
+ ); +} diff --git a/frontend/src/components/chart-page/chart-format.ts b/frontend/src/components/chart-page/chart-format.ts new file mode 100644 index 00000000..8461c8e0 --- /dev/null +++ b/frontend/src/components/chart-page/chart-format.ts @@ -0,0 +1,9 @@ +export function formatCoord(latitude: number, longitude: number): string { + const ns = latitude >= 0 ? "N" : "S"; + const ew = longitude >= 0 ? "E" : "W"; + return `${Math.abs(latitude).toFixed(2)}°${ns} ${Math.abs(longitude).toFixed(2)}°${ew}`; +} + +export function formatDegree(degree: number): string { + return `${Math.min(29.9, Math.max(0, degree)).toFixed(1)}°`; +} diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx new file mode 100644 index 00000000..fd08adfa --- /dev/null +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -0,0 +1,85 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { useState } from "react"; + +import { + CHART_VIEW_TABS, + type ChartViewResponse, + type ChartViewTabId, +} from "@/lib/chart-view-contract"; +import { ChartBasicsTab } from "./chart-basics-tab"; +import { ChartDashaTab } from "./chart-dasha-tab"; +import { ChartQizhengTab } from "./chart-qizheng-tab"; +import { ChartVedicTab } from "./chart-vedic-tab"; +import { ChartWesternTab } from "./chart-western-tab"; + +export function ChartPageView({ + view, + initialTab = "vedic", +}: { + view: ChartViewResponse; + initialTab?: ChartViewTabId; +}) { + const [tab, setTab] = useState(initialTab); + const [vargaId, setVargaId] = useState( + view.status === "ok" ? view.vedic.vargas[0]?.id ?? "D1" : "D1", + ); + + return ( +
+
+ +
+ + {view.status !== "ok" ? ( +
+

直接计算 · 打开即有 · 不消耗点数

+

星盘

+

{view.message}

+
+ ) : ( + <> +
+

直接计算 · 打开即有 · 不消耗点数

+

星盘

+

+ {view.profile.name} · {view.profile.date} {view.profile.time} · {view.profile.placeLabel} +

+
+
+ {CHART_VIEW_TABS.map((item) => ( + + ))} +
+
+ {tab === "vedic" ? ( + item.id === vargaId) ?? view.vedic.vargas[0]!} + onSelectVarga={setVargaId} + /> + ) : null} + {tab === "basics" ? : null} + {tab === "dasha" ? : null} + {tab === "western" ? : null} + {tab === "qizheng" ? : null} +
+ + )} +
+ ); +} diff --git a/frontend/src/components/chart-page/chart-param-lines.tsx b/frontend/src/components/chart-page/chart-param-lines.tsx new file mode 100644 index 00000000..95ab1cbf --- /dev/null +++ b/frontend/src/components/chart-page/chart-param-lines.tsx @@ -0,0 +1,31 @@ +import type { ChartViewOk } from "@/lib/chart-view-contract"; +import { formatCoord } from "./chart-format"; + +export function ChartParamLines({ + view, + compact, +}: { + view: ChartViewOk; + compact?: boolean; +}) { + const { profile } = view; + const place = `${profile.placeLabel} · ${formatCoord(profile.latitude, profile.longitude)}`; + if (compact) { + return ( + <> +

岁差 {profile.ayanamsaDisplay}

+

{profile.nodeModeLabel}

+ + ); + } + return ( + <> +

{profile.time} · {profile.birthTimeStatusLabel}

+

{place}

+

岁差 {profile.ayanamsaDisplay}

+

{profile.nodeModeLabel}

+

升 {profile.ascendantSignLabel} · 月宿 {profile.moonNakshatra} 第{profile.moonPada}足

+

{profile.currentDashaLabel} · {profile.engineName}

+ + ); +} diff --git a/frontend/src/components/chart-page/chart-qizheng-tab.tsx b/frontend/src/components/chart-page/chart-qizheng-tab.tsx new file mode 100644 index 00000000..deb9ae40 --- /dev/null +++ b/frontend/src/components/chart-page/chart-qizheng-tab.tsx @@ -0,0 +1,55 @@ +import type { ChartViewOk } from "@/lib/chart-view-contract"; +import { COORDINATE_BOUNDARY, QIZHENG_RULES } from "@/lib/chart-view-labels"; +import { QizhengPalaceGrid } from "./qizheng-palace-grid"; + +function Rules() { + return ( +
    + {QIZHENG_RULES.map((rule) =>
  • {rule}
  • )} +
+ ); +} + +export function ChartQizhengTab({ view }: { view: ChartViewOk }) { + const qizheng = view.qizheng; + if (qizheng.status === "unavailable") { + return ( +
+

{COORDINATE_BOUNDARY.qizheng}

+

{qizheng.copy}

+ +
+ ); + } + return ( +
+

{qizheng.boundary}

+

计都派别:{qizheng.ketuMode} · 坐标系 {qizheng.coordinateSystem}

+ + + + + + + + + + + + + + + {qizheng.bodies.map((body) => ( + + + + + + + + ))} + +
十一曜宿度
宿宿度庙旺
{body.name}{body.mansion}{body.mansionDegree.toFixed(2)}{body.palace}{body.dignityClosed ? body.dignity : `${body.dignity}(未闭合)`}
+
+ ); +} diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx new file mode 100644 index 00000000..83f1ba76 --- /dev/null +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg"; +import type { ChartViewOk, ChartViewVarga } from "@/lib/chart-view-contract"; +import { formatDegree } from "./chart-format"; +import { ChartParamLines } from "./chart-param-lines"; + +export function ChartVedicTab({ + view, + varga, + onSelectVarga, +}: { + view: ChartViewOk; + varga: ChartViewVarga; + onSelectVarga: (id: string) => void; +}) { + return ( +
+
+ {view.vedic.vargas.map((item) => ( + + ))} +
+
+
+ +
+
+ +
+
+ +
+
+
+
+ +

{view.vedic.boundary}

+
+
+ + + + + + + + + + + + {view.vedic.planets.map((planet) => ( + + + + + + + ))} + +
{varga.id} · {varga.meaning}
星体星座与度数宫位状态
{planet.label}{planet.signLabel} {formatDegree(planet.degreeInSign)}{planet.house}{planet.status}
+
+
+

{view.vedic.boundary}

+
+ ); +} diff --git a/frontend/src/components/chart-page/chart-western-tab.tsx b/frontend/src/components/chart-page/chart-western-tab.tsx new file mode 100644 index 00000000..1cb77e2d --- /dev/null +++ b/frontend/src/components/chart-page/chart-western-tab.tsx @@ -0,0 +1,53 @@ +import type { ChartViewOk } from "@/lib/chart-view-contract"; +import { COORDINATE_BOUNDARY } from "@/lib/chart-view-labels"; +import { WesternWheelSvg } from "./western-wheel-svg"; + +export function ChartWesternTab({ view }: { view: ChartViewOk }) { + const western = view.western; + if (western.status === "unavailable") { + return ( +
+

{COORDINATE_BOUNDARY.western}

+

{western.copy}

+
+ ); + } + return ( +
+
+ +
+

{western.boundary}

+

{western.ayanamsaGapNote}

+
+

元素 / 模式

+

{western.elements.map((item) => `${item.label} ${item.count}`).join(" · ")}

+

{western.modes.map((item) => `${item.label} ${item.count}`).join(" · ")}

+
+
+

主要相位

+ + + + + + + + + + {western.aspects.slice(0, 12).map((aspect) => ( + + + + + + ))} + +
星体相位容许度
{aspect.left} · {aspect.right}{aspect.aspectLabel}{aspect.orb.toFixed(1)}°
+
+

宫制 {western.houseSystem === "P" ? "Placidus" : western.houseSystem}

+
+
+
+ ); +} diff --git a/frontend/src/components/chart-page/qizheng-palace-grid.tsx b/frontend/src/components/chart-page/qizheng-palace-grid.tsx new file mode 100644 index 00000000..c8af345a --- /dev/null +++ b/frontend/src/components/chart-page/qizheng-palace-grid.tsx @@ -0,0 +1,24 @@ +import type { ChartViewQizhengOk } from "@/lib/chart-view-contract"; +import { QIZHENG_BRANCH_GRID } from "@/lib/chart-view-labels"; + +export function QizhengPalaceGrid({ qizheng }: { qizheng: ChartViewQizhengOk }) { + const byBranch = new Map(qizheng.palaces.map((palace) => [palace.branch, palace])); + return ( +
+ {QIZHENG_BRANCH_GRID.flatMap((row, rowIndex) => row.map((branch, columnIndex) => { + if (!branch) { + return
; + } + const palace = byBranch.get(branch); + return ( +
+ {branch} + {palace?.lifePalace ?? "—"} + {palace?.mansions.length ? palace.mansions.join(" ") : "所辖宿未返回"} +

{palace?.occupants.length ? palace.occupants.join(" ") : "空"}

+
+ ); + }))} +
+ ); +} diff --git a/frontend/src/components/chart-page/western-wheel-svg.tsx b/frontend/src/components/chart-page/western-wheel-svg.tsx new file mode 100644 index 00000000..f1c0c0c0 --- /dev/null +++ b/frontend/src/components/chart-page/western-wheel-svg.tsx @@ -0,0 +1,79 @@ +import type { ChartViewWesternOk } from "@/lib/chart-view-contract"; +import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels"; + +const SIZE = 400; +const CX = SIZE / 2; +const CY = SIZE / 2; +const OUTER = 188; +const ZODIAC = 168; +const HOUSE = 128; +const INNER = 78; + +function polar(radius: number, deg: number): { x: number; y: number } { + const rad = (deg * Math.PI) / 180; + return { x: CX + radius * Math.cos(rad), y: CY - radius * Math.sin(rad) }; +} + +function screenAngle(longitude: number, ascendant: number): number { + return 180 - (longitude - ascendant); +} + +export function WesternWheelSvg({ western }: { western: ChartViewWesternOk }) { + const asc = western.ascendantLongitude; + const signWedges = SIGN_ORDER.map((sign, index) => { + const mid = screenAngle(index * 30 + 15, asc); + return { sign, label: polar((ZODIAC + OUTER) / 2, mid) }; + }); + return ( + + + + + + {signWedges.map((wedge) => { + const start = polar(ZODIAC, screenAngle(SIGN_ORDER.indexOf(wedge.sign) * 30, asc)); + const outer = polar(OUTER, screenAngle(SIGN_ORDER.indexOf(wedge.sign) * 30, asc)); + return ( + + + + {signZh(wedge.sign)} + + + ); + })} + {western.houses.map((house) => { + const angle = screenAngle(house.longitude, asc); + const inner = polar(INNER, angle); + const outer = polar(ZODIAC, angle); + const label = polar((INNER + HOUSE) / 2, angle); + return ( + + + + {house.house} + + + ); + })} + {western.planets.map((planet) => { + const point = polar((HOUSE + INNER) / 2 + 10, screenAngle(planet.longitude, asc)); + return ( + + {planet.label} + + ); + })} + + 升 + + + ); +} diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts new file mode 100644 index 00000000..a5e55d0c --- /dev/null +++ b/frontend/src/lib/chart-view-contract.ts @@ -0,0 +1,205 @@ +import { z } from "zod"; + +const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); + +export const chartViewUnavailableLayerSchema = z.object({ + status: z.literal("unavailable"), + reason: z.string().min(1), + copy: z.string().min(1), +}); + +export const northIndianChartSchema = z.object({ + houses: z.array(z.object({ + houseNumber: z.number().int().min(1).max(12), + sign: z.string().min(1), + occupants: z.array(z.string()), + })).length(12), + planets: z.array(z.object({ + name: z.string().min(1), + retrograde: z.boolean(), + })).optional(), +}); + +export const chartViewPlanetRowSchema = z.object({ + name: z.string().min(1), + label: z.string().min(1), + sign: z.string().min(1), + signLabel: z.string().min(1), + degreeInSign: z.number(), + house: z.number().int().min(1).max(12), + retrograde: z.boolean(), + status: z.string().min(1), + nakshatra: z.string().min(1), + pada: z.number().int().min(1).max(4), + nakshatraLord: z.string().min(1), + d9Sign: z.string().nullable(), + d9SignLabel: z.string().nullable(), + symbol: z.string().nullable(), +}); + +export const chartViewVargaSchema = z.object({ + id: z.string().regex(/^D\d{1,3}$/), + title: z.string().min(1), + meaning: z.string().min(1), + division: z.number().int().positive(), + chart: northIndianChartSchema, +}); + +export const chartViewPeriodSchema = z.object({ + lord: z.string().min(1), + label: z.string().min(1), + start: isoDate, + end: isoDate, + current: z.boolean(), + derived: z.boolean(), +}); + +export const chartViewMahadashaSchema = chartViewPeriodSchema.extend({ + sign: z.string().nullable(), + antardashas: z.array(chartViewPeriodSchema), +}); + +export const chartViewDashaTrackSchema = z.object({ + id: z.enum(["vimshottari", "chara"]), + title: z.string().min(1), + method: z.string().min(1), + currentLabel: z.string().min(1), + antardashaNote: z.string().nullable(), + periods: z.array(chartViewMahadashaSchema), +}); + +export const westernPointSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + longitude: z.number(), + sign: z.string().min(1), + signLabel: z.string().min(1), + degreeInSign: z.number(), + house: z.number().int().min(1).max(12).nullable(), + retrograde: z.boolean(), +}); + +export const chartViewWesternOkSchema = z.object({ + status: z.literal("ok"), + zodiac: z.literal("tropical"), + houseSystem: z.string().min(1), + boundary: z.string().min(1), + ayanamsaGapNote: z.string().min(1), + ascendantLongitude: z.number(), + planets: z.array(westernPointSchema), + houses: z.array(z.object({ + house: z.number().int().min(1).max(12), + longitude: z.number(), + sign: z.string().min(1), + signLabel: z.string().min(1), + degreeInSign: z.number(), + })).length(12), + aspects: z.array(z.object({ + left: z.string().min(1), + right: z.string().min(1), + aspect: z.string().min(1), + aspectLabel: z.string().min(1), + orb: z.number(), + })), + elements: z.array(z.object({ + id: z.string().min(1), + label: z.string().min(1), + count: z.number().int().nonnegative(), + })), + modes: z.array(z.object({ + id: z.string().min(1), + label: z.string().min(1), + count: z.number().int().nonnegative(), + })), +}); + +export const chartViewQizhengOkSchema = z.object({ + status: z.literal("ok"), + coordinateSystem: z.string().min(1), + ketuMode: z.string().min(1), + boundary: z.string().min(1), + palaces: z.array(z.object({ + branch: z.string().min(1), + lifePalace: z.string().min(1), + mansions: z.array(z.string()), + occupants: z.array(z.string()), + })), + bodies: z.array(z.object({ + name: z.string().min(1), + mansion: z.string().min(1), + mansionDegree: z.number(), + palace: z.string().min(1), + dignity: z.string().min(1), + dignityClosed: z.boolean(), + })), +}); + +export const chartViewProfileSchema = z.object({ + name: z.string().min(1), + date: isoDate, + time: z.string().regex(/^\d{2}:\d{2}/), + placeLabel: z.string().min(1), + latitude: z.number(), + longitude: z.number(), + timezoneLabel: z.string().min(1), + ayanamsa: z.string().min(1), + ayanamsaDisplay: z.string().min(1), + nodeMode: z.enum(["mean", "true"]), + nodeModeLabel: z.string().min(1), + birthTimeStatus: z.string().min(1), + birthTimeStatusLabel: z.string().min(1), + engineName: z.string().min(1), + moonNakshatra: z.string().min(1), + moonPada: z.number().int().min(1).max(4), + ascendantSign: z.string().min(1), + ascendantSignLabel: z.string().min(1), + currentDashaLabel: z.string().min(1), +}); + +export const chartViewOkSchema = z.object({ + status: z.literal("ok"), + billed: z.literal(false), + profile: chartViewProfileSchema, + vedic: z.object({ + coordinateSystem: z.literal("sidereal"), + boundary: z.string().min(1), + vargas: z.array(chartViewVargaSchema).min(1), + planets: z.array(chartViewPlanetRowSchema).min(1), + symbolBoundary: z.string().min(1), + }), + dasha: z.object({ + vimshottari: chartViewDashaTrackSchema, + chara: chartViewDashaTrackSchema, + dualTrackNote: z.string().min(1), + }), + western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]), + qizheng: z.union([chartViewQizhengOkSchema, chartViewUnavailableLayerSchema]), +}); + +export const chartViewMessageSchema = z.object({ + status: z.enum(["unauthenticated", "birth_profile_incomplete", "chart_unavailable", "rate_limited"]), + billed: z.literal(false), + message: z.string().min(1), +}); + +export const chartViewResponseSchema = z.union([chartViewOkSchema, chartViewMessageSchema]); + +export type ChartViewOk = z.infer; +export type ChartViewMessage = z.infer; +export type ChartViewResponse = z.infer; +export type ChartViewUnavailableLayer = z.infer; +export type ChartViewWesternOk = z.infer; +export type ChartViewQizhengOk = z.infer; +export type ChartViewVarga = z.infer; +export type ChartViewPlanetRow = z.infer; +export type NorthIndianChart = z.infer; + +export const CHART_VIEW_TABS = [ + { id: "vedic", label: "星盘" }, + { id: "basics", label: "基础信息" }, + { id: "dasha", label: "大运" }, + { id: "western", label: "西洋盘" }, + { id: "qizheng", label: "七政四余" }, +] as const; + +export type ChartViewTabId = (typeof CHART_VIEW_TABS)[number]["id"]; diff --git a/frontend/src/lib/chart-view-labels.ts b/frontend/src/lib/chart-view-labels.ts new file mode 100644 index 00000000..9196131d --- /dev/null +++ b/frontend/src/lib/chart-view-labels.ts @@ -0,0 +1,169 @@ +export const PLANET_ORDER = [ + "Sun", + "Moon", + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn", + "Rahu", + "Ketu", +] as const; + +export type PlanetName = (typeof PLANET_ORDER)[number]; + +export const PLANET_ZH: Record = { + Sun: "太阳", + Moon: "月亮", + Mars: "火星", + Mercury: "水星", + Jupiter: "木星", + Venus: "金星", + Saturn: "土星", + Rahu: "罗睺", + Ketu: "计都", +}; + +export const SIGN_ORDER = [ + "Aries", + "Taurus", + "Gemini", + "Cancer", + "Leo", + "Virgo", + "Libra", + "Scorpio", + "Sagittarius", + "Capricorn", + "Aquarius", + "Pisces", +] as const; + +export type SignName = (typeof SIGN_ORDER)[number]; + +export const SIGN_ZH: Record = { + Aries: "白羊", + Taurus: "金牛", + Gemini: "双子", + Cancer: "巨蟹", + Leo: "狮子", + Virgo: "处女", + Libra: "天秤", + Scorpio: "天蝎", + Sagittarius: "射手", + Capricorn: "摩羯", + Aquarius: "水瓶", + Pisces: "双鱼", +}; + +export const VARGA_CHIP_ORDER = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 20, 24, 27, 30, +] as const; + +export const VIMSHOTTARI_ORDER = [ + "Ketu", + "Venus", + "Sun", + "Moon", + "Mars", + "Rahu", + "Jupiter", + "Saturn", + "Mercury", +] as const; + +export const VIMSHOTTARI_YEARS: Record<(typeof VIMSHOTTARI_ORDER)[number], number> = { + Ketu: 7, + Venus: 20, + Sun: 6, + Moon: 10, + Mars: 7, + Rahu: 18, + Jupiter: 16, + Saturn: 19, + Mercury: 17, +}; + +export const WESTERN_BODY_ZH: Record = { + sun: "太阳", + moon: "月亮", + mercury: "水星", + venus: "金星", + mars: "火星", + jupiter: "木星", + saturn: "土星", + uranus: "天王", + neptune: "海王", + pluto: "冥王", + true_node: "北交", + ascendant: "上升", + mc: "中天", +}; + +export const ASPECT_ZH: Record = { + conjunction: "合相", + sextile: "六分相", + square: "刑相", + trine: "三分相", + opposition: "对相", +}; + +export const ELEMENT_ZH: Record = { + fire: "火", + earth: "土", + air: "风", + water: "水", +}; + +export const MODE_ZH: Record = { + cardinal: "基本", + fixed: "固定", + mutable: "变动", +}; + +export const QIZHENG_BRANCH_GRID: ReadonlyArray = [ + ["巳", "午", "未", "申"], + ["辰", null, null, "酉"], + ["卯", null, null, "戌"], + ["寅", "丑", "子", "亥"], +]; + +export const COORDINATE_BOUNDARY = { + vedic: "这是恒星黄道,岁差用你资料里选的那一套。它和西洋回归黄道、七政宿度不是同一套坐标,不能换算,也不能叠成双重印证。", + western: "这是回归黄道,宫位用 Placidus。它和印度盘的恒星黄经、七政宿度不是同一套,不能换算,也不能叠成双重印证。", + qizheng: "宿度从角宿初度起算,不是恒星黄经,也不是回归黄道。三套不能换算,也不能叠成双重印证。", +} as const; + +export const DASHA_DUAL_TRACK_NOTE = "只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。"; + +export const ANTARDASHA_DERIVED_NOTE = "分运按标准比例从大运边界推出,不是引擎单独返回的字段。"; + +export const CHARA_ANTARDASHA_DATE_NOTE = "分运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。"; + +export const SYMBOL_BOUNDARY = "下面是词条式释义,不是对你个人的判断。"; + +export const WESTERN_UNAVAILABLE_COPY = "西洋盘这一栏要等回归黄道端点上线。现在还没有,先空着——不会拿印度盘的度数改写成回归黄道。"; + +export const QIZHENG_UNAVAILABLE_COPY = "七政四余这一栏要等宿度端点上线。现在还没有,先空着。"; + +export const QIZHENG_RULES = [ + "宿度从角宿初度起算,不是恒星黄经。", + "计都派别(是否取罗睺对点)会改整张盘,必须写明,不能静默默认。", + "庙旺表还没闭合,这一列只能当占位,不能拿来下判断。", +] as const; + +export function planetZh(name: string): string { + return PLANET_ZH[name as PlanetName] ?? name; +} + +export function signZh(sign: string): string { + return SIGN_ZH[sign as SignName] ?? sign; +} + +export function isSignName(value: string): value is SignName { + return (SIGN_ORDER as readonly string[]).includes(value); +} + +export function isPlanetName(value: string): value is PlanetName { + return (PLANET_ORDER as readonly string[]).includes(value); +} diff --git a/frontend/src/lib/chart-view-load.ts b/frontend/src/lib/chart-view-load.ts new file mode 100644 index 00000000..c43d1839 --- /dev/null +++ b/frontend/src/lib/chart-view-load.ts @@ -0,0 +1,124 @@ +import { resolveAyanamsa } from "./ayanamsa.ts"; +import { + chartViewMessageSchema, + type ChartViewResponse, +} from "./chart-view-contract.ts"; +import { VARGA_CHIP_ORDER } from "./chart-view-labels.ts"; +import { + buildChartView, + engineChartHasPlanets, + type ChartViewProfileInput, +} from "./chart-view-mapper.ts"; + +export type ChartViewEnginePost = ( + path: string, + body: Record, +) => Promise | null>; + +export type ChartViewLoadInput = { + userId: string | null; + profile: ChartViewProfileInput | null; + postEngine: ChartViewEnginePost; + asOf: string; + rateLimited?: boolean; +}; + +function message( + status: "unauthenticated" | "birth_profile_incomplete" | "chart_unavailable" | "rate_limited", + copy: string, +): ChartViewResponse { + return chartViewMessageSchema.parse({ status, billed: false, message: copy }); +} + +function birthPayload(profile: ChartViewProfileInput): Record { + const [year, month, day] = profile.date.split("-").map(Number); + const [hour, minute] = profile.time.split(":").map(Number); + return { + year, + month, + day, + hour, + minute, + second: 0, + lat: profile.latitude, + lon: profile.longitude, + tz: profile.timezoneOffset, + latitude: profile.latitude, + longitude: profile.longitude, + timezone: profile.timezoneOffset, + ayanamsa: resolveAyanamsa({ ayanamsa: profile.ayanamsa }), + node_mode: "mean", + house_system: "P", + }; +} + +export async function assembleChartView(input: ChartViewLoadInput): Promise<{ + httpStatus: number; + body: ChartViewResponse; +}> { + if (!input.userId) { + return { + httpStatus: 401, + body: message("unauthenticated", "请先登录后再看星盘。"), + }; + } + if (input.rateLimited) { + return { + httpStatus: 429, + body: message("rate_limited", "刚才打开得太勤,稍等再试。"), + }; + } + const profile = input.profile; + if (!profile) { + return { + httpStatus: 200, + body: message("birth_profile_incomplete", "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。"), + }; + } + + const payload = birthPayload(profile); + const chart = await input.postEngine("/api/chart", payload); + if (!chart || chart.success === false || !engineChartHasPlanets(chart)) { + return { + httpStatus: 200, + body: message("chart_unavailable", "这张盘这会儿算不出来。资料还在,过一会儿再打开。"), + }; + } + + const planets = chart.planets; + const ascendant = chart.ascendant; + const houses = chart.houses; + const followUp = { + ...payload, + planets, + ascendant, + houses, + }; + const [chara, varga, western, qizheng] = await Promise.all([ + input.postEngine("/api/dasha/chara", { ...followUp, antardasha: true, mode: "dasha" }), + input.postEngine("/api/varga_full", { + ...followUp, + divisions: VARGA_CHIP_ORDER.map((division) => `D${division}`), + }), + input.postEngine("/api/western", payload), + input.postEngine("/api/qizheng", payload), + ]); + + try { + const body = buildChartView({ + chart, + varga, + chara, + western, + qizheng, + profile, + asOf: input.asOf, + }); + return { httpStatus: 200, body }; + } catch { + return { + httpStatus: 200, + body: message("chart_unavailable", "这张盘这会儿算不出来。资料还在,过一会儿再打开。"), + }; + } +} diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts new file mode 100644 index 00000000..10a26dd0 --- /dev/null +++ b/frontend/src/lib/chart-view-mapper.ts @@ -0,0 +1,670 @@ +import { + toNorthIndianChart, + type ReportChartBlock, +} from "./report-chart-block.ts"; +import { + ANTARDASHA_DERIVED_NOTE, + CHARA_ANTARDASHA_DATE_NOTE, + COORDINATE_BOUNDARY, + DASHA_DUAL_TRACK_NOTE, + PLANET_ORDER, + QIZHENG_UNAVAILABLE_COPY, + SIGN_ORDER, + SYMBOL_BOUNDARY, + VARGA_CHIP_ORDER, + VIMSHOTTARI_ORDER, + VIMSHOTTARI_YEARS, + WESTERN_UNAVAILABLE_COPY, + ASPECT_ZH, + ELEMENT_ZH, + MODE_ZH, + WESTERN_BODY_ZH, + isSignName, + planetZh, + signZh, +} from "./chart-view-labels.ts"; +import { traditionalSymbol } from "./chart-view-symbols.ts"; +import { + chartViewOkSchema, + type ChartViewOk, + type ChartViewQizhengOk, + type ChartViewUnavailableLayer, + type ChartViewVarga, + type ChartViewWesternOk, + type NorthIndianChart, +} from "./chart-view-contract.ts"; + +export type ChartViewProfileInput = { + name?: string; + date: string; + time: string; + placeLabel?: string; + latitude: number; + longitude: number; + timezoneOffset: number; + timezoneId?: string; + ayanamsa?: string; + birthTimeStatus?: string; +}; + +export type ChartViewEngineBundle = { + chart: Record; + varga: Record | null; + chara: Record | null; + western: Record | null; + qizheng: Record | null; + profile: ChartViewProfileInput; + asOf: string; +}; + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function finite(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +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 + weight, 0); + const before = weights.slice(0, index).reduce((sum, weight) => sum + weight, 0); + const span = end - start; + const from = start + span * (before / total); + const to = index === weights.length - 1 ? end : start + span * ((before + weights[index]!) / total); + 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); +} + +function containsDay(start: string, end: string, asOf: string): boolean { + return start <= asOf && asOf < end; +} + +function asPlanetMap(value: unknown): Record> { + const raw = record(value); + if (!raw) return {}; + const out: Record> = {}; + for (const [name, planet] of Object.entries(raw)) { + const row = record(planet); + if (row) out[name] = row; + } + return out; +} + +function planetLon(planet: Record): number | null { + return finite(planet.lon) ?? finite(planet.longitude) ?? finite(planet.degree_raw); +} + +function planetDegreeInSign(planet: Record): number { + return finite(planet.degree_in_sign) ?? finite(planet.degree) ?? ((planetLon(planet) ?? 0) % 30); +} + +function statusLabel(planet: Record): string { + const retrograde = Boolean(planet.retrograde); + const dignity = text(planet.status) ?? "中性"; + return retrograde && !dignity.includes("逆") ? `${dignity} · 逆行` : dignity; +} + +function birthTimeStatusLabel(status: string): string { + if (status === "confirmed") return "已校正"; + if (status === "accepted") return "已采用"; + return "填报时间"; +} + +function nodeModeLabel(mode: "mean" | "true"): string { + return mode === "true" ? "真交点" : "平交点"; +} + +function formatCoord(latitude: number, longitude: number): string { + const ns = latitude >= 0 ? "N" : "S"; + const ew = longitude >= 0 ? "E" : "W"; + return `${Math.abs(latitude).toFixed(2)}°${ns} ${Math.abs(longitude).toFixed(2)}°${ew}`; +} + +function houseFromPlanet(planet: Record, ascSign: string): number { + const direct = finite(planet.house); + if (direct !== null && direct >= 1 && direct <= 12) return Math.trunc(direct); + const sign = text(planet.sign); + if (!sign || !isSignName(sign) || !isSignName(ascSign)) return 1; + return ((SIGN_ORDER.indexOf(sign) - SIGN_ORDER.indexOf(ascSign) + 12) % 12) + 1; +} + +function natalBlock(chart: Record): ReportChartBlock | null { + const planets = asPlanetMap(chart.planets); + const ascendant = record(chart.ascendant); + const ascSign = text(ascendant?.sign); + if (!ascSign || !isSignName(ascSign)) return null; + const rows: ReportChartBlock["planets"] = []; + for (const name of PLANET_ORDER) { + const planet = planets[name]; + if (!planet) continue; + const sign = text(planet.sign); + if (!sign || !isSignName(sign)) continue; + rows.push({ + name, + sign, + degree: Math.min(29.999, Math.max(0, planetDegreeInSign(planet))), + retrograde: Boolean(planet.retrograde), + }); + } + if (rows.length === 0) return null; + return { + version: 1, + id: "D1", + title: "D1 — 本命盘", + layout: "north", + ascendant: { + sign: ascSign, + degree: Math.min(29.999, Math.max(0, finite(ascendant?.degree_in_sign) ?? finite(ascendant?.degree) ?? 0)), + }, + planets: rows, + }; +} + +function vargaKey(division: number, result: Record): Record | null { + const exact = Object.entries(result).find(([key]) => key === `D${division}` || key.startsWith(`D${division}_`)); + return exact ? record(exact[1]) : null; +} + +function vargaToChart( + id: string, + title: string, + varga: Record, + natalRetro: ReadonlyMap, +): { chart: NorthIndianChart; meaning: string; division: number } | null { + const ascendant = record(varga.ascendant); + const ascSign = text(ascendant?.sign); + if (!ascSign || !isSignName(ascSign)) return null; + const planets = asPlanetMap(varga.planets); + const rows: ReportChartBlock["planets"] = []; + for (const name of PLANET_ORDER) { + const planet = planets[name]; + if (!planet) continue; + const sign = text(planet.sign); + if (!sign || !isSignName(sign)) continue; + rows.push({ + name, + sign, + degree: Math.min(29.999, Math.max(0, planetDegreeInSign(planet))), + retrograde: natalRetro.get(name) ?? false, + }); + } + const division = finite(varga.division) ?? Number(id.slice(1)); + const meaning = text(varga.meaning) ?? title; + const block: ReportChartBlock = { + version: 1, + id: id as ReportChartBlock["id"], + title, + layout: "north", + ascendant: { + sign: ascSign, + degree: Math.min(29.999, Math.max(0, finite(ascendant?.degree) ?? 0)), + }, + planets: rows, + }; + return { chart: toNorthIndianChart(block), meaning, division }; +} + +function buildVargas( + chart: Record, + vargaPacket: Record | null, +): ChartViewVarga[] { + const natalRetro = new Map(); + for (const [name, planet] of Object.entries(asPlanetMap(chart.planets))) { + natalRetro.set(name, Boolean(planet.retrograde)); + } + const result = record(vargaPacket?.result) ?? {}; + const vargas: ChartViewVarga[] = []; + for (const division of VARGA_CHIP_ORDER) { + const raw = vargaKey(division, result); + const id = `D${division}`; + if (raw) { + const mapped = vargaToChart(id, `${id} ${text(raw.name) ?? ""}`.trim(), raw, natalRetro); + if (mapped) { + vargas.push({ + id, + title: id, + meaning: mapped.meaning, + division: mapped.division, + chart: mapped.chart, + }); + continue; + } + } + if (division === 1) { + const block = natalBlock(chart); + if (block) { + vargas.push({ + id: "D1", + title: "D1", + meaning: "本命盘", + division: 1, + chart: toNorthIndianChart(block), + }); + } + } + } + return vargas; +} + +function d9SignFor(name: string, vargaPacket: Record | null): string | null { + const result = record(vargaPacket?.result) ?? {}; + const d9 = vargaKey(9, result); + const planet = asPlanetMap(d9?.planets)[name]; + return text(planet?.sign); +} + +function planetRows(chart: Record, vargaPacket: Record | null) { + const planets = asPlanetMap(chart.planets); + const ascSign = text(record(chart.ascendant)?.sign) ?? "Aries"; + return PLANET_ORDER.flatMap((name) => { + const planet = planets[name]; + if (!planet) return []; + const sign = text(planet.sign) ?? "Aries"; + const d9 = d9SignFor(name, vargaPacket); + return [{ + name, + label: planetZh(name), + sign, + signLabel: signZh(sign), + degreeInSign: planetDegreeInSign(planet), + house: houseFromPlanet(planet, ascSign), + retrograde: Boolean(planet.retrograde), + status: statusLabel(planet), + nakshatra: text(planet.nakshatra) ?? "—", + pada: Math.min(4, Math.max(1, Math.trunc(finite(planet.nakshatra_pada) ?? 1))), + nakshatraLord: planetZh(text(planet.nakshatra_lord) ?? "—"), + d9Sign: d9, + d9SignLabel: d9 ? signZh(d9) : null, + symbol: traditionalSymbol(name, sign), + }]; + }); +} + +function vimshottariTrack(chart: Record, asOf: string) { + const dasha = record(chart.dasha) ?? {}; + const rawPeriods = Array.isArray(dasha.periods) ? dasha.periods : []; + const periods = rawPeriods.flatMap((entry) => { + const row = record(entry); + if (!row) return []; + const lord = text(row.lord); + const start = text(row.start); + const end = text(row.end); + if (!lord || !start || !end) return []; + const startIso = isoDay(start); + const endIso = isoDay(end); + const startIndex = VIMSHOTTARI_ORDER.indexOf(lord as (typeof VIMSHOTTARI_ORDER)[number]); + const order = startIndex >= 0 + ? VIMSHOTTARI_ORDER.map((_, index) => VIMSHOTTARI_ORDER[(startIndex + index) % VIMSHOTTARI_ORDER.length]!) + : [...VIMSHOTTARI_ORDER]; + const weights = order.map((name) => VIMSHOTTARI_YEARS[name]); + const antardashas = order.map((subLord, index) => { + const span = addWeightedSpan(startIso, endIso, index, weights); + return { + lord: subLord, + label: planetZh(subLord), + start: span.start, + end: span.end, + current: containsDay(span.start, span.end, asOf), + derived: true, + }; + }); + return [{ + lord, + label: planetZh(lord), + sign: null, + start: startIso, + end: endIso, + current: containsDay(startIso, endIso, asOf), + derived: false, + antardashas, + }]; + }); + const current = periods.find((item) => item.current); + const currentAntar = current?.antardashas.find((item) => item.current); + const currentLabel = current + ? `${planetZh(current.lord)}大运 / ${currentAntar ? planetZh(currentAntar.lord) : "—"}分运` + : "当前大运未落入已返回的时段"; + return { + id: "vimshottari" as const, + title: "Vimshottari", + method: "Vimshottari", + currentLabel, + antardashaNote: ANTARDASHA_DERIVED_NOTE, + periods, + }; +} + +function charaTrack(charaPacket: Record | null, birthDate: string, asOf: string) { + const result = record(charaPacket?.result) ?? charaPacket; + const chara = record(result?.chara_dasha) ?? result; + const sequence = Array.isArray(chara?.dasha_sequence) ? chara.dasha_sequence : []; + let cursor = birthDate; + const periods = sequence.flatMap((entry) => { + const row = record(entry); + if (!row) return []; + const sign = text(row.sign); + const lord = text(row.lord) ?? sign ?? "—"; + const duration = finite(row.duration_years) ?? 1; + const start = cursor; + const end = addYears(start, duration); + cursor = end; + const rawAntar = Array.isArray(row.antardashas) ? row.antardashas : []; + const antardashas = rawAntar.length > 0 + ? rawAntar.flatMap((antar, index) => { + const item = record(antar); + if (!item) return []; + const weights = rawAntar.map((candidate) => finite(record(candidate)?.duration_years) ?? 1); + const span = addWeightedSpan(start, end, index, weights); + const antarLord = text(item.lord) ?? text(item.sign) ?? "—"; + return [{ + lord: antarLord, + label: `${signZh(text(item.sign) ?? antarLord)} / ${planetZh(antarLord)}`, + start: span.start, + end: span.end, + current: containsDay(span.start, span.end, asOf), + derived: true, + }]; + }) + : SIGN_ORDER.map((antarSign, index) => { + const span = addWeightedSpan(start, end, index, SIGN_ORDER.map(() => 1)); + return { + lord: antarSign, + label: signZh(antarSign), + start: span.start, + end: span.end, + current: containsDay(span.start, span.end, asOf), + derived: true, + }; + }); + return [{ + lord, + label: `${signZh(sign ?? lord)} / ${planetZh(lord)}`, + sign: sign, + start, + end, + current: containsDay(start, end, asOf), + derived: false, + antardashas, + }]; + }); + const current = periods.find((item) => item.current); + const currentAntar = current?.antardashas.find((item) => item.current); + const currentLabel = current + ? `${current.label}${currentAntar ? ` / ${currentAntar.label}` : ""}` + : "当前 Chara 段未落入已返回的时段"; + return { + id: "chara" as const, + title: "Chara Dasha(kn_rao 变体)", + method: text(chara?.method) === "Chara Dasha (kn_rao)" + ? "Chara Dasha(kn_rao 变体)" + : (text(chara?.method) ?? "Chara Dasha(kn_rao 变体)"), + currentLabel, + antardashaNote: CHARA_ANTARDASHA_DATE_NOTE, + periods, + }; +} + +function unavailableWestern(reason: string): ChartViewUnavailableLayer { + return { status: "unavailable", reason, copy: WESTERN_UNAVAILABLE_COPY }; +} + +function unavailableQizheng(reason: string): ChartViewUnavailableLayer { + return { status: "unavailable", reason, copy: QIZHENG_UNAVAILABLE_COPY }; +} + +function mapWestern(raw: Record | null, ayanamsaDisplay: string): ChartViewWesternOk | ChartViewUnavailableLayer { + if (!raw) return unavailableWestern("endpoint_missing"); + const natal = record(raw.natal) ?? raw; + const ascendant = record(natal.ascendant); + const planetsRaw = record(natal.planets); + const housesRaw = Array.isArray(natal.houses) ? natal.houses : []; + if (!ascendant || !planetsRaw || housesRaw.length !== 12) return unavailableWestern("payload_incomplete"); + const ascLon = finite(ascendant.longitude); + if (ascLon === null) return unavailableWestern("payload_incomplete"); + const planets = Object.entries(planetsRaw).flatMap(([id, value]) => { + const planet = record(value); + if (!planet) return []; + const longitude = finite(planet.longitude); + const sign = text(planet.sign); + if (longitude === null || !sign) return []; + return [{ + id, + label: WESTERN_BODY_ZH[id] ?? id, + longitude, + sign, + signLabel: signZh(sign), + degreeInSign: finite(planet.degree_in_sign) ?? (longitude % 30), + house: finite(planet.house) !== null ? Math.trunc(finite(planet.house)!) : null, + retrograde: Boolean(planet.retrograde), + }]; + }); + const houses = housesRaw.flatMap((entry, index) => { + const row = record(entry); + const cusp = record(row?.cusp) ?? row; + if (!cusp) return []; + const longitude = finite(cusp.longitude); + const sign = text(cusp.sign); + if (longitude === null || !sign) return []; + return [{ + house: Math.trunc(finite(row?.house) ?? index + 1), + longitude, + sign, + signLabel: signZh(sign), + degreeInSign: finite(cusp.degree_in_sign) ?? (longitude % 30), + }]; + }); + if (planets.length === 0 || houses.length !== 12) return unavailableWestern("payload_incomplete"); + const aspectsRaw = Array.isArray(natal.aspects) ? natal.aspects : []; + const aspects = aspectsRaw.flatMap((entry) => { + const row = record(entry); + if (!row) return []; + const left = text(row.left); + const right = text(row.right); + const aspect = text(row.aspect); + if (!left || !right || !aspect) return []; + return [{ + left: WESTERN_BODY_ZH[left] ?? left, + right: WESTERN_BODY_ZH[right] ?? right, + aspect, + aspectLabel: ASPECT_ZH[aspect] ?? aspect, + orb: finite(row.orb) ?? 0, + }]; + }); + const distribution = record(natal.distribution); + const elementsRaw = record(distribution?.elements) ?? {}; + const modesRaw = record(distribution?.modes) ?? {}; + return { + status: "ok", + zodiac: "tropical", + houseSystem: text(raw.house_system) ?? "P", + boundary: COORDINATE_BOUNDARY.western, + ayanamsaGapNote: `回归黄道与本页印度盘所用${ayanamsaDisplay}岁差不是同一套;差值随日期变,不能把两边度数对过去。`, + ascendantLongitude: ascLon, + planets, + houses, + aspects, + elements: ["fire", "earth", "air", "water"].map((id) => ({ + id, + label: ELEMENT_ZH[id] ?? id, + count: Math.max(0, Math.trunc(finite(elementsRaw[id]) ?? 0)), + })), + modes: ["cardinal", "fixed", "mutable"].map((id) => ({ + id, + label: MODE_ZH[id] ?? id, + count: Math.max(0, Math.trunc(finite(modesRaw[id]) ?? 0)), + })), + }; +} + +function palaceBranch(row: Record): string | null { + return text(row.branch) ?? text(row.zhi) ?? text(row.earthly_branch) ?? text(row.name); +} + +function mapQizheng(raw: Record | null): ChartViewQizhengOk | ChartViewUnavailableLayer { + if (!raw) return unavailableQizheng("endpoint_missing"); + const palacesRaw = raw.palaces; + const bodiesRaw = raw.bodies ?? raw.planets; + const palaces: ChartViewQizhengOk["palaces"] = []; + if (Array.isArray(palacesRaw)) { + for (const entry of palacesRaw) { + const row = record(entry); + if (!row) continue; + const branch = palaceBranch(row); + if (!branch) continue; + palaces.push({ + branch, + lifePalace: text(row.life_palace) ?? text(row.lifePalace) ?? text(row.human_palace) ?? "—", + mansions: Array.isArray(row.mansions) + ? row.mansions.filter((item): item is string => typeof item === "string") + : text(row.mansions) + ? [String(row.mansions)] + : (text(row.xiu) ? [String(row.xiu)] : []), + occupants: Array.isArray(row.occupants) + ? row.occupants.filter((item): item is string => typeof item === "string") + : [], + }); + } + } else { + const asRecord = record(palacesRaw); + if (asRecord) { + for (const [branch, value] of Object.entries(asRecord)) { + const row = record(value) ?? {}; + palaces.push({ + branch, + lifePalace: text(row.life_palace) ?? text(row.lifePalace) ?? "—", + mansions: Array.isArray(row.mansions) + ? row.mansions.filter((item): item is string => typeof item === "string") + : [], + occupants: Array.isArray(row.occupants) + ? row.occupants.filter((item): item is string => typeof item === "string") + : [], + }); + } + } + } + const bodies: ChartViewQizhengOk["bodies"] = []; + if (Array.isArray(bodiesRaw)) { + for (const entry of bodiesRaw) { + const row = record(entry); + if (!row) continue; + const name = text(row.name) ?? text(row.body) ?? text(row.planet); + if (!name) continue; + bodies.push({ + name, + mansion: text(row.mansion) ?? text(row.xiu) ?? "—", + mansionDegree: finite(row.mansion_degree) ?? finite(row.siderealLon) ?? finite(row.degree) ?? 0, + palace: text(row.palace) ?? text(row.branch) ?? "—", + dignity: text(row.dignity) ?? text(row.status) ?? "未闭合", + dignityClosed: false, + }); + } + } else { + const asRecord = record(bodiesRaw); + if (asRecord) { + for (const [name, value] of Object.entries(asRecord)) { + const row = record(value) ?? {}; + bodies.push({ + name, + mansion: text(row.mansion) ?? text(row.xiu) ?? "—", + mansionDegree: finite(row.mansion_degree) ?? finite(row.siderealLon) ?? finite(row.degree) ?? 0, + palace: text(row.palace) ?? text(row.branch) ?? "—", + dignity: text(row.dignity) ?? "未闭合", + dignityClosed: false, + }); + } + } + } + if (palaces.length === 0 || bodies.length === 0) return unavailableQizheng("payload_incomplete"); + return { + status: "ok", + coordinateSystem: text(raw.coordinate_system) ?? "qizheng_mansion_degrees_from_jiao", + ketuMode: text(raw.ketu_mode) ?? text(raw.ketuMode) ?? "unspecified", + boundary: COORDINATE_BOUNDARY.qizheng, + palaces, + bodies, + }; +} + +export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { + const { chart, varga, chara, western, qizheng, profile, asOf } = bundle; + const planets = asPlanetMap(chart.planets); + const moon = planets.Moon ?? {}; + const ascendant = record(chart.ascendant); + const birth = record(chart.birth) ?? {}; + const ayanamsa = text(birth.ayanamsa_name) ?? profile.ayanamsa ?? "raman"; + const ayanamsaDisplay = text(birth.ayanamsa_display) ?? ayanamsa; + const nodeMode = text(birth.node_mode) === "true" ? "true" : "mean"; + const vargas = buildVargas(chart, varga); + if (vargas.length === 0) throw new Error("vedic_chart_unavailable"); + const vimshottari = vimshottariTrack(chart, asOf); + const charaMapped = charaTrack(chara, profile.date, asOf); + const placeLabel = profile.placeLabel?.trim() + || formatCoord(profile.latitude, profile.longitude); + const view = { + status: "ok" as const, + billed: false as const, + profile: { + name: profile.name?.trim() || "未命名", + date: profile.date, + time: profile.time.slice(0, 5), + placeLabel, + latitude: profile.latitude, + longitude: profile.longitude, + timezoneLabel: profile.timezoneId || `UTC${profile.timezoneOffset >= 0 ? "+" : ""}${profile.timezoneOffset}`, + ayanamsa, + ayanamsaDisplay, + nodeMode, + nodeModeLabel: nodeModeLabel(nodeMode), + birthTimeStatus: profile.birthTimeStatus ?? "reported", + birthTimeStatusLabel: birthTimeStatusLabel(profile.birthTimeStatus ?? "reported"), + engineName: "Swiss Ephemeris(本站)", + moonNakshatra: text(moon.nakshatra) ?? "—", + moonPada: Math.min(4, Math.max(1, Math.trunc(finite(moon.nakshatra_pada) ?? 1))), + ascendantSign: text(ascendant?.sign) ?? "—", + ascendantSignLabel: signZh(text(ascendant?.sign) ?? "—"), + currentDashaLabel: vimshottari.currentLabel, + }, + vedic: { + coordinateSystem: "sidereal" as const, + boundary: COORDINATE_BOUNDARY.vedic, + vargas, + planets: planetRows(chart, varga), + symbolBoundary: SYMBOL_BOUNDARY, + }, + dasha: { + vimshottari, + chara: charaMapped, + dualTrackNote: DASHA_DUAL_TRACK_NOTE, + }, + western: mapWestern(western, ayanamsaDisplay), + qizheng: mapQizheng(qizheng), + }; + return chartViewOkSchema.parse(view); +} + +export function engineChartHasPlanets(chart: Record): boolean { + return PLANET_ORDER.some((name) => Boolean(asPlanetMap(chart.planets)[name])); +} + + diff --git a/frontend/src/lib/chart-view-service.ts b/frontend/src/lib/chart-view-service.ts new file mode 100644 index 00000000..c58f215a --- /dev/null +++ b/frontend/src/lib/chart-view-service.ts @@ -0,0 +1,107 @@ +import "server-only"; + +import { consumeRequestRateLimit } from "./request-rate-limit.ts"; +import { + ACCOUNT_BIRTH_SELECT, + globalBirthProfileFromAccountRow, +} from "./server-owned-birth-profile.ts"; +import { createServerSupabaseClient } from "./supabase/server.ts"; +import { assembleChartView, type ChartViewEnginePost } from "./chart-view-load.ts"; +import type { ChartViewProfileInput } from "./chart-view-mapper.ts"; +import type { ChartViewResponse } from "./chart-view-contract.ts"; + +const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; +const engineTimeoutMs = 45_000; + +function text(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function finite(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +async function postEngine(path: string, body: Record): Promise | null> { + try { + const response = await fetch(`${jyotishApiBase}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + cache: "no-store", + signal: AbortSignal.timeout(engineTimeoutMs), + }); + if (!response.ok) return null; + const payload = await response.json().catch(() => null); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + return payload as Record; + } catch { + return null; + } +} + +function profileFromRow(row: unknown): ChartViewProfileInput | null { + const profile = globalBirthProfileFromAccountRow(row); + const record = row && typeof row === "object" && !Array.isArray(row) + ? row as Record + : {}; + if (!profile.date || !profile.time) return null; + const latitude = finite(profile.latitude); + const longitude = finite(profile.longitude); + const timezoneOffset = finite(profile.timezoneOffset); + if (latitude === undefined || longitude === undefined || timezoneOffset === undefined) return null; + return { + name: profile.name, + date: profile.date, + time: profile.time, + placeLabel: text(record.birth_place_label), + latitude, + longitude, + timezoneOffset, + timezoneId: profile.timezoneId, + ayanamsa: typeof profile.ayanamsa === "string" ? profile.ayanamsa : undefined, + birthTimeStatus: profile.birthTimeStatus, + }; +} + +export async function loadChartView(now = new Date()): Promise<{ + httpStatus: number; + body: ChartViewResponse; +}> { + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return assembleChartView({ + userId: null, + profile: null, + postEngine: postEngine as ChartViewEnginePost, + asOf: now.toISOString().slice(0, 10), + }); + } + + const limited = consumeRequestRateLimit({ + key: `chartView:${user.id}`, + limit: 20, + windowMs: 60_000, + }); + + const { data: row } = await supabase + .from("profiles") + .select(ACCOUNT_BIRTH_SELECT) + .eq("id", user.id) + .maybeSingle(); + + return assembleChartView({ + userId: user.id, + profile: profileFromRow(row), + postEngine, + asOf: now.toISOString().slice(0, 10), + rateLimited: !limited.ok, + }); +} diff --git a/frontend/src/lib/chart-view-symbols.ts b/frontend/src/lib/chart-view-symbols.ts new file mode 100644 index 00000000..7d707cbf --- /dev/null +++ b/frontend/src/lib/chart-view-symbols.ts @@ -0,0 +1,41 @@ +import { type PlanetName, type SignName } from "./chart-view-labels.ts"; + +/** Traditional keyword lines. Not a personal reading. */ +const SYMBOLS: Partial>>> = { + Sun: { + Aries: "词条:意志、起头、公开露面", + Taurus: "词条:稳定、资源、感官享受", + Gemini: "词条:说话、学习、短途往来", + Cancer: "词条:家、照顾、情绪根基", + Leo: "词条:表达、被看见、创造性权威", + Virgo: "词条:技艺、条理、日常工作", + Libra: "词条:伙伴、审美、公开关系", + Scorpio: "词条:深度、共享资源、转化", + Sagittarius: "词条:远方、信念、教学", + Capricorn: "词条:责任、结构、社会位置", + Aquarius: "词条:群体、革新、疏离的理想", + Pisces: "词条:退隐、想象、无边界的同情", + }, + Moon: { + Aries: "词条:情绪来得快、要立刻反应", + Taurus: "词条:安稳、身体舒适、持有", + Gemini: "词条:心思活、要说话才安", + Cancer: "词条:护家、记忆、潮汐般的心情", + Leo: "词条:要被看见才踏实", + Virgo: "词条:用做事来安情绪", + Libra: "词条:要有人在、要公平", + Scorpio: "词条:感受深、不轻易摊开", + Sagittarius: "词条:要空间、要意义", + Capricorn: "词条:克制、责任压过心情", + Aquarius: "词条:疏离、从群体里找归属", + Pisces: "词条:渗透、梦、边界薄", + }, +}; + +export function traditionalSymbol(planet: string, sign: string): string | null { + const byPlanet = SYMBOLS[planet as PlanetName]; + if (!byPlanet) { + return "词条:传统象征按星体与星座检索,这一格只列事实位置。"; + } + return byPlanet[sign as SignName] ?? "词条:传统象征按星体与星座检索,这一格只列事实位置。"; +} diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx new file mode 100644 index 00000000..8bf9719a --- /dev/null +++ b/frontend/tests/chart-page-view.test.tsx @@ -0,0 +1,147 @@ +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 { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx"; +import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import { CHART_VIEW_TABS, type ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { cssDeclarations } from "./css-contract-test-support.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; + chara: Record; + western: Record; +}; +const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8"); +const vedicTab = readFileSync(new URL("../src/components/chart-page/chart-vedic-tab.tsx", import.meta.url), "utf8"); +const vedicSvg = readFileSync(new URL("../src/components/personal-report/vedic-chart-svg.tsx", import.meta.url), "utf8"); + +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(western: Record | null = null): Promise { + const result = await assembleChartView({ + userId: "user-1", + profile, + asOf: "2026-09-15", + postEngine: async (path) => { + if (path === "/api/chart") return golden.chart; + if (path === "/api/varga_full") return golden.varga_full; + if (path === "/api/dasha/chara") return golden.chara; + if (path === "/api/western") return western; + return null; + }, + }); + assert.equal(result.body.status, "ok"); + return result.body as ChartViewOk; +} + +test("the five tabs render and can each be selected", async () => { + const view = await okView(); + for (const tab of CHART_VIEW_TABS) { + const markup = renderToStaticMarkup(React.createElement(ChartPageView, { view, initialTab: tab.id })); + assert.match(markup, /直接计算 · 打开即有 · 不消耗点数/); + assert.match(markup, />星盘基础信息大运西洋盘七政四余 { + const view = await okView(); + const western = renderToStaticMarkup(React.createElement(ChartPageView, { view, initialTab: "western" })); + const qizheng = renderToStaticMarkup(React.createElement(ChartPageView, { view, initialTab: "qizheng" })); + assert.doesNotMatch(western, /报错|出错了|500/); + assert.doesNotMatch(qizheng, /报错|出错了|500/); + assert.match(western, /不能换算/); + assert.match(qizheng, /不能换算/); +}); + +test("a tropical packet draws the western wheel instead of the unavailable copy", async () => { + const view = await okView(golden.western); + const markup = renderToStaticMarkup(React.createElement(ChartPageView, { view, initialTab: "western" })); + assert.match(markup, /回归黄道本命盘/); + assert.doesNotMatch(markup, /西洋盘这一栏要等回归黄道端点上线/); +}); + +test("the North Indian svg is imported in place and its export signature is unchanged", () => { + assert.match(vedicTab, /from "@\/components\/personal-report\/vedic-chart-svg"/); + assert.match(vedicSvg, /export function VedicChartSvg/); + assert.match(vedicSvg, /export const NorthIndianChartSvg = VedicChartSvg/); + assert.doesNotMatch(vedicTab, /chart-page\/vedic-chart-svg/); +}); + +test("sidebar adds 星盘 and 星历 after 新建对话 without renaming 我的报告", () => { + const header = sidebar.slice(sidebar.indexOf("")); + const newChat = header.indexOf("新建对话"); + const chart = header.indexOf(">星盘<"); + const ephemeris = header.indexOf(">星历<"); + const reports = header.indexOf("我的报告"); + assert.ok(newChat >= 0 && chart > newChat && ephemeris > chart && reports > ephemeris); + assert.match(sidebar, /router\.push\("\/chart"\)/); + assert.match(sidebar, /router\.push\("\/ephemeris"\)/); + assert.match(sidebar, /tooltip="星盘"/); + assert.match(sidebar, /tooltip="星历"/); +}); + +test("mobile width keeps two centre lines and moves the full parameter card below the chart", () => { + assert.match( + globalStyles, + /@media \(max-width: 767px\) \{\r?\n \.chart-page-hero h1[\s\S]*\.chart-page-center-compact \{ display: block; \}/, + ); + assert.match( + globalStyles, + /@media \(max-width: 767px\) \{\r?\n \.chart-page-hero h1[\s\S]*\.chart-page-center-extra \{ display: none; \}/, + ); + assert.match( + globalStyles, + /@media \(max-width: 767px\) \{\r?\n \.chart-page-hero h1[\s\S]*\.chart-page-params-below \{ display: block; \}/, + ); + assert.match(cssDeclarations(".chart-page-tab", globalStyles), /min-height:\s*44px/); + assert.match(cssDeclarations(".chart-page-chip", globalStyles), /min-height:\s*44px/); + assert.match(cssDeclarations(".chart-page-back", globalStyles), /min-height:\s*44px/); + assert.match(globalStyles, /\.chart-page-vedic-stage \.personal-report-chart-svg text \{ font-size: 16px; \}/); +}); + +test("an incomplete profile page has no loading animation", () => { + const markup = renderToStaticMarkup(React.createElement(ChartPageView, { + view: { status: "birth_profile_incomplete", billed: false, message: "还没有可用来排盘的出生资料。" }, + })); + assert.match(markup, /还没有可用来排盘的出生资料/); + assert.doesNotMatch(markup, /正在加载|InlineSpinner|skeleton/i); +}); diff --git a/frontend/tests/chart-view-route.test.ts b/frontend/tests/chart-view-route.test.ts new file mode 100644 index 00000000..fa4a2a55 --- /dev/null +++ b/frontend/tests/chart-view-route.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import { chartViewOkSchema, chartViewResponseSchema } from "../src/lib/chart-view-contract.ts"; +import { COORDINATE_BOUNDARY } from "../src/lib/chart-view-labels.ts"; + +const golden = JSON.parse( + readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"), +) as { + chart: Record; + varga_full: Record; + chara: Record; + western: Record; +}; + +const routeSource = readFileSync(new URL("../src/app/api/chart-view/route.ts", import.meta.url), "utf8"); +const serviceSource = readFileSync(new URL("../src/lib/chart-view-service.ts", import.meta.url), "utf8"); +const loadSource = readFileSync(new URL("../src/lib/chart-view-load.ts", import.meta.url), "utf8"); +const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + +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" as const, +}; + +function engine(overrides: Record | null> = {}) { + const calls: string[] = []; + const postEngine = async (path: string) => { + calls.push(path); + if (path in overrides) return overrides[path]; + if (path === "/api/chart") return golden.chart; + if (path === "/api/varga_full") return golden.varga_full; + if (path === "/api/dasha/chara") return golden.chara; + if (path === "/api/western") return null; + if (path === "/api/qizheng") return null; + return null; + }; + return { calls, postEngine }; +} + +test("the chart-view BFF never bills and never calls a model", () => { + for (const source of [routeSource, serviceSource, loadSource]) { + assert.doesNotMatch(source, /consultation-billing/); + assert.doesNotMatch(source, /@\/mastra/); + assert.doesNotMatch(source, /authorizeUsage|completeUsage|releaseUsage/); + } + assert.match(routeSource, /export async function GET/); + assert.match(serviceSource, /createServerSupabaseClient/); + assert.match(serviceSource, /ACCOUNT_BIRTH_SELECT/); + assert.match(loadSource, /\/api\/chart/); + assert.match(loadSource, /\/api\/dasha\/chara/); + assert.match(loadSource, /\/api\/varga_full/); + assert.match(loadSource, /planets/); + assert.match(loadSource, /ascendant/); + assert.match(loadSource, /houses/); +}); + +test("unauthenticated chart-view requests are 401", async () => { + const { calls, postEngine } = engine(); + const result = await assembleChartView({ + userId: null, + profile, + postEngine, + asOf: "2026-09-15", + }); + assert.equal(result.httpStatus, 401); + assert.equal(result.body.status, "unauthenticated"); + assert.equal(result.body.billed, false); + assert.equal(calls.length, 0); +}); + +test("an incomplete birth profile returns a structured message, not 500", async () => { + const { calls, postEngine } = engine(); + const result = await assembleChartView({ + userId: "user-1", + profile: null, + postEngine, + asOf: "2026-09-15", + }); + assert.equal(result.httpStatus, 200); + assert.equal(result.body.status, "birth_profile_incomplete"); + assert.notEqual(result.httpStatus, 500); + assert.match(result.body.message, /出生资料/); + assert.equal(calls.length, 0); +}); + +test("western and qizheng stay unavailable without failing the Indian tabs", async () => { + const { calls, postEngine } = engine(); + const result = await assembleChartView({ + userId: "user-1", + profile, + postEngine, + asOf: "2026-09-15", + }); + assert.equal(result.httpStatus, 200); + const body = chartViewOkSchema.parse(result.body); + assert.equal(body.billed, false); + assert.ok(body.vedic.vargas.some((item) => item.id === "D1")); + assert.ok(body.vedic.planets.length >= 9); + assert.equal(body.dasha.vimshottari.method, "Vimshottari"); + assert.equal(body.dasha.chara.title, "Chara Dasha(kn_rao 变体)"); + assert.equal(body.western.status, "unavailable"); + assert.equal(body.qizheng.status, "unavailable"); + assert.ok(body.vedic.vargas.some((item) => item.id === "D9")); + assert.equal(calls.includes("/api/western"), true); + assert.equal(calls.includes("/api/qizheng"), true); + assert.doesNotMatch(calls.join(" "), /billing|mastra|consult/); +}); + +test("a real western engine packet lights the tropical tab without mixing sidereal longitudes", async () => { + const { postEngine } = engine({ "/api/western": golden.western, "/api/qizheng": null }); + const result = await assembleChartView({ + userId: "user-1", + profile, + postEngine, + asOf: "2026-09-15", + }); + const body = chartViewOkSchema.parse(result.body); + assert.equal(body.western.status, "ok"); + if (body.western.status !== "ok") throw new Error("expected western ok"); + assert.equal(body.western.zodiac, "tropical"); + assert.equal(body.western.houses.length, 12); + assert.match(body.western.boundary, /回归黄道/); + assert.notEqual(body.western.ascendantLongitude, body.vedic.planets[0]?.degreeInSign); + assert.equal(body.qizheng.status, "unavailable"); +}); + +test("the golden chart-view envelope stays inside the page contract", () => { + chartViewResponseSchema.parse({ + status: "birth_profile_incomplete", + billed: false, + message: "还没有可用来排盘的出生资料。", + }); + assert.match(COORDINATE_BOUNDARY.vedic, /不能换算/); + assert.match(COORDINATE_BOUNDARY.western, /不能换算/); + assert.match(COORDINATE_BOUNDARY.qizheng, /角宿/); +}); + +test("page.tsx does not grow to host the chart page", () => { + assert.ok((pageSource.match(/\n/g) ?? []).length <= 1951); + assert.doesNotMatch(pageSource, /chart-page|ChartPageView|\/api\/chart-view/); +}); diff --git a/frontend/tests/fixtures/chart-view-golden.json b/frontend/tests/fixtures/chart-view-golden.json new file mode 100644 index 00000000..28a7d37b --- /dev/null +++ b/frontend/tests/fixtures/chart-view-golden.json @@ -0,0 +1,3793 @@ +{ + "sourceCommand": "anaconda python: domain_calculation_service.compute_chart + compute_vimshottari_timeline; divisional_charts_extended.DivisionalChartsCalculator; jaimini.calc_chara_dasha_with_antardasha (pratyantar_dashas stripped; unused by the page); western_chart_engine.build_tropical_natal_chart. Fictional 1990-06-15 12:00 Beijing 39.9042N 116.4074E tz+8 Raman mean node.", + "chart": { + "success": true, + "birth": { + "date": "1990-06-15", + "time": "12:00", + "hour": 12, + "minute": 0, + "second": 0, + "tz": "UTC+8.0", + "lat": 39.9042, + "lon": 116.4074, + "julian_day": 2448057.666667, + "ayanamsa": 22.2774, + "ayanamsa_name": "raman", + "ayanamsa_display": "Raman", + "node_mode": "mean", + "node_mode_note": "mean=Mean Node; true=True Node. PyJHora默认true,本skill默认mean。" + }, + "ascendant": { + "sign": "Leo", + "sign_cn": "狮子座", + "degree": 29.3994, + "degree_raw": 149.3994, + "degree_in_sign": 29.3994, + "degree_in_sign_raw": 29.399370365697422, + "lon": 149.3994, + "lord": "Sun" + }, + "planets": { + "Sun": { + "sign": "Gemini", + "lon": 61.53377206924905, + "degree": 1.5338, + "degree_in_sign": 1.5338, + "house": 11, + "retrograde": false, + "nakshatra": "Mrigashira", + "nakshatra_pada": 3, + "nakshatra_lord": "Mars", + "status": "入友(Friendly Sign)", + "sign_idx": 2 + }, + "Moon": { + "sign": "Aquarius", + "lon": 318.6461748848373, + "degree": 18.6462, + "degree_in_sign": 18.6462, + "house": 7, + "retrograde": false, + "nakshatra": "Shatabhisha", + "nakshatra_pada": 4, + "nakshatra_lord": "Rahu", + "status": "入友(Friendly Sign)", + "sign_idx": 10 + }, + "Mars": { + "sign": "Pisces", + "lon": 348.5241802675653, + "degree": 18.5242, + "degree_in_sign": 18.5242, + "house": 8, + "retrograde": false, + "nakshatra": "Revati", + "nakshatra_pada": 1, + "nakshatra_lord": "Mercury", + "status": "极友(Great Friend)", + "sign_idx": 11 + }, + "Mercury": { + "sign": "Taurus", + "lon": 42.84385254472214, + "degree": 12.8439, + "degree_in_sign": 12.8439, + "house": 10, + "retrograde": false, + "nakshatra": "Rohini", + "nakshatra_pada": 1, + "nakshatra_lord": "Moon", + "status": "极友(Great Friend)", + "sign_idx": 1 + }, + "Jupiter": { + "sign": "Gemini", + "lon": 83.53983317966238, + "degree": 23.5398, + "degree_in_sign": 23.5398, + "house": 11, + "retrograde": false, + "nakshatra": "Punarvasu", + "nakshatra_pada": 2, + "nakshatra_lord": "Jupiter", + "status": "中性(Neutral)", + "sign_idx": 2 + }, + "Venus": { + "sign": "Aries", + "lon": 26.108246111989978, + "degree": 26.1082, + "degree_in_sign": 26.1082, + "house": 9, + "retrograde": false, + "nakshatra": "Bharani", + "nakshatra_pada": 4, + "nakshatra_lord": "Venus", + "status": "入友(Friendly Sign)", + "sign_idx": 0 + }, + "Saturn": { + "sign": "Capricorn", + "lon": 271.7740297190386, + "degree": 1.774, + "degree_in_sign": 1.774, + "house": 6, + "retrograde": true, + "nakshatra": "Uttara Ashadha", + "nakshatra_pada": 2, + "nakshatra_lord": "Sun", + "status": "入庙(Own Sign)", + "sign_idx": 9 + }, + "Rahu": { + "sign": "Capricorn", + "lon": 287.4381184213972, + "degree": 17.4381, + "degree_in_sign": 17.4381, + "house": 6, + "retrograde": true, + "nakshatra": "Shravana", + "nakshatra_pada": 3, + "nakshatra_lord": "Moon", + "status": "中性(Neutral)", + "sign_idx": 9 + }, + "Ketu": { + "sign": "Cancer", + "lon": 107.43811842139718, + "degree": 17.4381, + "degree_in_sign": 17.4381, + "house": 12, + "retrograde": true, + "nakshatra": "Ashlesha", + "nakshatra_pada": 1, + "nakshatra_lord": "Mercury", + "status": "极敌(Great Enemy)", + "sign_idx": 3 + } + }, + "houses": { + "1": { + "sign": "Leo", + "sign_idx": null, + "cusp_degree": 149.3994 + }, + "2": { + "sign": "Virgo", + "sign_idx": null, + "cusp_degree": 179.3994 + }, + "3": { + "sign": "Libra", + "sign_idx": null, + "cusp_degree": 209.3994 + }, + "4": { + "sign": "Scorpio", + "sign_idx": null, + "cusp_degree": 239.3994 + }, + "5": { + "sign": "Sagittarius", + "sign_idx": null, + "cusp_degree": 269.3994 + }, + "6": { + "sign": "Capricorn", + "sign_idx": null, + "cusp_degree": 299.3994 + }, + "7": { + "sign": "Aquarius", + "sign_idx": null, + "cusp_degree": 329.3994 + }, + "8": { + "sign": "Pisces", + "sign_idx": null, + "cusp_degree": 359.3994 + }, + "9": { + "sign": "Aries", + "sign_idx": null, + "cusp_degree": 29.3994 + }, + "10": { + "sign": "Taurus", + "sign_idx": null, + "cusp_degree": 59.3994 + }, + "11": { + "sign": "Gemini", + "sign_idx": null, + "cusp_degree": 89.3994 + }, + "12": { + "sign": "Cancer", + "sign_idx": null, + "cusp_degree": 119.3994 + } + }, + "dasha": { + "current_md": "Rahu", + "remaining_years": 1.83, + "periods": [ + { + "lord": "Rahu", + "years": 18, + "start": "1974-04-13", + "end": "1992-04-12" + }, + { + "lord": "Jupiter", + "years": 16, + "start": "1992-04-12", + "end": "2008-04-12" + }, + { + "lord": "Saturn", + "years": 19, + "start": "2008-04-12", + "end": "2027-04-13" + }, + { + "lord": "Mercury", + "years": 17, + "start": "2027-04-13", + "end": "2044-04-12" + }, + { + "lord": "Ketu", + "years": 7, + "start": "2044-04-12", + "end": "2051-04-13" + }, + { + "lord": "Venus", + "years": 20, + "start": "2051-04-13", + "end": "2071-04-13" + }, + { + "lord": "Sun", + "years": 6, + "start": "2071-04-13", + "end": "2077-04-12" + }, + { + "lord": "Moon", + "years": 10, + "start": "2077-04-12", + "end": "2087-04-13" + }, + { + "lord": "Mars", + "years": 7, + "start": "2087-04-13", + "end": "2094-04-13" + } + ], + "birth_balance": { + "lord": "Rahu", + "elapsed_years": 16.17, + "remaining_years": 1.83 + }, + "calculation_contract": { + "contract_version": "1.0.0", + "algorithm": "vimshottari_birth_balance", + "requested": { + "moon_longitude": 318.6461748848373 + }, + "effective": { + "year_basis_days": 365.25, + "nakshatra": "Shatabhisha", + "pada": 4 + } + } + }, + "calculation_contract": { + "contract_version": "1.0.0", + "algorithm": "sidereal_natal_chart", + "requested": { + "ayanamsa": "raman", + "node_mode": "mean", + "timezone_offset": 8 + }, + "effective": { + "ayanamsa": "raman", + "node_mode": "mean", + "timezone_offset": 8.0, + "timezone_source": "explicit_offset", + "ephemeris_source": "swisseph_calc_ut", + "ephemeris_flags_verified": false + } + } + }, + "varga_full": { + "success": true, + "endpoint": "varga_full", + "result": { + "D1_Rashi": { + "division": 1, + "name": "Rashi", + "meaning": "本命盘", + "ascendant": { + "sign": "Leo", + "sign_index": 4, + "degree": 29.3994, + "absolute_degree": 149.3994 + }, + "planets": { + "Sun": { + "sign": "Gemini", + "sign_index": 2, + "degree": 1.5338, + "house": 11, + "absolute_degree": 61.5338 + }, + "Moon": { + "sign": "Aquarius", + "sign_index": 10, + "degree": 18.6462, + "house": 7, + "absolute_degree": 318.6462 + }, + "Mars": { + "sign": "Pisces", + "sign_index": 11, + "degree": 18.5242, + "house": 8, + "absolute_degree": 348.5242 + }, + "Mercury": { + "sign": "Taurus", + "sign_index": 1, + "degree": 12.8439, + "house": 10, + "absolute_degree": 42.8439 + }, + "Jupiter": { + "sign": "Gemini", + "sign_index": 2, + "degree": 23.5398, + "house": 11, + "absolute_degree": 83.5398 + }, + "Venus": { + "sign": "Aries", + "sign_index": 0, + "degree": 26.1082, + "house": 9, + "absolute_degree": 26.1082 + }, + "Saturn": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 1.774, + "house": 6, + "absolute_degree": 271.774 + }, + "Rahu": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 17.4381, + "house": 6, + "absolute_degree": 287.4381 + }, + "Ketu": { + "sign": "Cancer", + "sign_index": 3, + "degree": 17.4381, + "house": 12, + "absolute_degree": 107.4381 + } + }, + "house_chart": [ + [], + [], + [], + [], + [], + [ + "Saturn", + "Rahu" + ], + [ + "Moon" + ], + [ + "Mars" + ], + [ + "Venus" + ], + [ + "Mercury" + ], + [ + "Sun", + "Jupiter" + ], + [ + "Ketu" + ] + ] + }, + "D2_Hora": { + "division": 2, + "name": "Hora", + "meaning": "财富", + "ascendant": { + "sign": "Cancer", + "sign_index": 3, + "degree": 28.7988, + "absolute_degree": 118.7988 + }, + "planets": { + "Sun": { + "sign": "Leo", + "sign_index": 4, + "degree": 3.0675, + "house": 2, + "absolute_degree": 123.0675 + }, + "Moon": { + "sign": "Cancer", + "sign_index": 3, + "degree": 7.2923, + "house": 1, + "absolute_degree": 97.2923 + }, + "Mars": { + "sign": "Leo", + "sign_index": 4, + "degree": 7.0484, + "house": 2, + "absolute_degree": 127.0484 + }, + "Mercury": { + "sign": "Cancer", + "sign_index": 3, + "degree": 25.6877, + "house": 1, + "absolute_degree": 115.6877 + }, + "Jupiter": { + "sign": "Cancer", + "sign_index": 3, + "degree": 17.0797, + "house": 1, + "absolute_degree": 107.0797 + }, + "Venus": { + "sign": "Cancer", + "sign_index": 3, + "degree": 22.2165, + "house": 1, + "absolute_degree": 112.2165 + }, + "Saturn": { + "sign": "Cancer", + "sign_index": 3, + "degree": 3.5481, + "house": 1, + "absolute_degree": 93.5481 + }, + "Rahu": { + "sign": "Leo", + "sign_index": 4, + "degree": 4.8762, + "house": 2, + "absolute_degree": 124.8762 + }, + "Ketu": { + "sign": "Leo", + "sign_index": 4, + "degree": 4.8762, + "house": 2, + "absolute_degree": 124.8762 + } + }, + "house_chart": [ + [ + "Moon", + "Mercury", + "Jupiter", + "Venus", + "Saturn" + ], + [ + "Sun", + "Mars", + "Rahu", + "Ketu" + ], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [] + ] + }, + "D3_Drekkana": { + "division": 3, + "name": "Drekkana", + "meaning": "兄弟姐妹", + "ascendant": { + "sign": "Aries", + "sign_index": 0, + "degree": 28.1982, + "absolute_degree": 28.1982 + }, + "planets": { + "Sun": { + "sign": "Gemini", + "sign_index": 2, + "degree": 4.6013, + "house": 3, + "absolute_degree": 64.6013 + }, + "Moon": { + "sign": "Gemini", + "sign_index": 2, + "degree": 25.9385, + "house": 3, + "absolute_degree": 85.9385 + }, + "Mars": { + "sign": "Cancer", + "sign_index": 3, + "degree": 25.5725, + "house": 4, + "absolute_degree": 115.5725 + }, + "Mercury": { + "sign": "Virgo", + "sign_index": 5, + "degree": 8.5316, + "house": 6, + "absolute_degree": 158.5316 + }, + "Jupiter": { + "sign": "Aquarius", + "sign_index": 10, + "degree": 10.6195, + "house": 11, + "absolute_degree": 310.6195 + }, + "Venus": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 18.3247, + "house": 9, + "absolute_degree": 258.3247 + }, + "Saturn": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 5.3221, + "house": 10, + "absolute_degree": 275.3221 + }, + "Rahu": { + "sign": "Taurus", + "sign_index": 1, + "degree": 22.3144, + "house": 2, + "absolute_degree": 52.3144 + }, + "Ketu": { + "sign": "Scorpio", + "sign_index": 7, + "degree": 22.3144, + "house": 8, + "absolute_degree": 232.3144 + } + }, + "house_chart": [ + [], + [ + "Rahu" + ], + [ + "Sun", + "Moon" + ], + [ + "Mars" + ], + [], + [ + "Mercury" + ], + [], + [ + "Ketu" + ], + [ + "Venus" + ], + [ + "Saturn" + ], + [ + "Jupiter" + ], + [] + ] + }, + "D4_Chaturthamsa": { + "division": 4, + "name": "Chaturthamsa", + "meaning": "财产/运气", + "ascendant": { + "sign": "Taurus", + "sign_index": 1, + "degree": 27.5976, + "absolute_degree": 57.5976 + }, + "planets": { + "Sun": { + "sign": "Gemini", + "sign_index": 2, + "degree": 6.1351, + "house": 2, + "absolute_degree": 66.1351 + }, + "Moon": { + "sign": "Leo", + "sign_index": 4, + "degree": 14.5847, + "house": 4, + "absolute_degree": 134.5847 + }, + "Mars": { + "sign": "Virgo", + "sign_index": 5, + "degree": 14.0967, + "house": 5, + "absolute_degree": 164.0967 + }, + "Mercury": { + "sign": "Leo", + "sign_index": 4, + "degree": 21.3754, + "house": 4, + "absolute_degree": 141.3754 + }, + "Jupiter": { + "sign": "Pisces", + "sign_index": 11, + "degree": 4.1593, + "house": 11, + "absolute_degree": 334.1593 + }, + "Venus": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 14.433, + "house": 9, + "absolute_degree": 284.433 + }, + "Saturn": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 7.0961, + "house": 9, + "absolute_degree": 277.0961 + }, + "Rahu": { + "sign": "Cancer", + "sign_index": 3, + "degree": 9.7525, + "house": 3, + "absolute_degree": 99.7525 + }, + "Ketu": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 9.7525, + "house": 9, + "absolute_degree": 279.7525 + } + }, + "house_chart": [ + [], + [ + "Sun" + ], + [ + "Rahu" + ], + [ + "Moon", + "Mercury" + ], + [ + "Mars" + ], + [], + [], + [], + [ + "Venus", + "Saturn", + "Ketu" + ], + [], + [ + "Jupiter" + ], + [] + ] + }, + "D5_Panchamsa": { + "division": 5, + "name": "Panchamsa", + "meaning": "名声/权力", + "ascendant": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 26.997, + "absolute_degree": 266.997 + }, + "planets": { + "Sun": { + "sign": "Gemini", + "sign_index": 2, + "degree": 7.6689, + "house": 7, + "absolute_degree": 67.6689 + }, + "Moon": { + "sign": "Taurus", + "sign_index": 1, + "degree": 3.2309, + "house": 6, + "absolute_degree": 33.2309 + }, + "Mars": { + "sign": "Aquarius", + "sign_index": 10, + "degree": 2.6209, + "house": 3, + "absolute_degree": 302.6209 + }, + "Mercury": { + "sign": "Pisces", + "sign_index": 11, + "degree": 4.2193, + "house": 4, + "absolute_degree": 334.2193 + }, + "Jupiter": { + "sign": "Virgo", + "sign_index": 5, + "degree": 27.6992, + "house": 10, + "absolute_degree": 177.6992 + }, + "Venus": { + "sign": "Leo", + "sign_index": 4, + "degree": 10.5412, + "house": 9, + "absolute_degree": 130.5412 + }, + "Saturn": { + "sign": "Virgo", + "sign_index": 5, + "degree": 8.8701, + "house": 10, + "absolute_degree": 158.8701 + }, + "Rahu": { + "sign": "Scorpio", + "sign_index": 7, + "degree": 27.1906, + "house": 12, + "absolute_degree": 237.1906 + }, + "Ketu": { + "sign": "Taurus", + "sign_index": 1, + "degree": 27.1906, + "house": 6, + "absolute_degree": 57.1906 + } + }, + "house_chart": [ + [], + [], + [ + "Mars" + ], + [ + "Mercury" + ], + [], + [ + "Moon", + "Ketu" + ], + [ + "Sun" + ], + [], + [ + "Venus" + ], + [ + "Jupiter", + "Saturn" + ], + [], + [ + "Rahu" + ] + ] + }, + "D6_Shashthamsa": { + "division": 6, + "name": "Shashthamsa", + "meaning": "健康/敌人", + "ascendant": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 26.3964, + "absolute_degree": 296.3964 + }, + "planets": { + "Sun": { + "sign": "Gemini", + "sign_index": 2, + "degree": 9.2026, + "house": 6, + "absolute_degree": 69.2026 + }, + "Moon": { + "sign": "Taurus", + "sign_index": 1, + "degree": 21.877, + "house": 5, + "absolute_degree": 51.877 + }, + "Mars": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 21.1451, + "house": 12, + "absolute_degree": 261.1451 + }, + "Mercury": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 17.0631, + "house": 1, + "absolute_degree": 287.0631 + }, + "Jupiter": { + "sign": "Libra", + "sign_index": 6, + "degree": 21.239, + "house": 10, + "absolute_degree": 201.239 + }, + "Venus": { + "sign": "Virgo", + "sign_index": 5, + "degree": 6.6495, + "house": 9, + "absolute_degree": 156.6495 + }, + "Saturn": { + "sign": "Cancer", + "sign_index": 3, + "degree": 10.6442, + "house": 7, + "absolute_degree": 100.6442 + }, + "Rahu": { + "sign": "Libra", + "sign_index": 6, + "degree": 14.6287, + "house": 10, + "absolute_degree": 194.6287 + }, + "Ketu": { + "sign": "Aries", + "sign_index": 0, + "degree": 14.6287, + "house": 4, + "absolute_degree": 14.6287 + } + }, + "house_chart": [ + [ + "Mercury" + ], + [], + [], + [ + "Ketu" + ], + [ + "Moon" + ], + [ + "Sun" + ], + [ + "Saturn" + ], + [], + [ + "Venus" + ], + [ + "Jupiter", + "Rahu" + ], + [], + [ + "Mars" + ] + ] + }, + "D7_Saptamsa": { + "division": 7, + "name": "Saptamsa", + "meaning": "子女", + "ascendant": { + "sign": "Aquarius", + "sign_index": 10, + "degree": 25.7958, + "absolute_degree": 325.7958 + }, + "planets": { + "Sun": { + "sign": "Gemini", + "sign_index": 2, + "degree": 10.7364, + "house": 5, + "absolute_degree": 70.7364 + }, + "Moon": { + "sign": "Gemini", + "sign_index": 2, + "degree": 10.5232, + "house": 5, + "absolute_degree": 70.5232 + }, + "Mars": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 9.6693, + "house": 12, + "absolute_degree": 279.6693 + }, + "Mercury": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 29.907, + "house": 12, + "absolute_degree": 299.907 + }, + "Jupiter": { + "sign": "Scorpio", + "sign_index": 7, + "degree": 14.7788, + "house": 10, + "absolute_degree": 224.7788 + }, + "Venus": { + "sign": "Libra", + "sign_index": 6, + "degree": 2.7577, + "house": 9, + "absolute_degree": 182.7577 + }, + "Saturn": { + "sign": "Cancer", + "sign_index": 3, + "degree": 12.4182, + "house": 6, + "absolute_degree": 102.4182 + }, + "Rahu": { + "sign": "Scorpio", + "sign_index": 7, + "degree": 2.0668, + "house": 10, + "absolute_degree": 212.0668 + }, + "Ketu": { + "sign": "Taurus", + "sign_index": 1, + "degree": 2.0668, + "house": 4, + "absolute_degree": 32.0668 + } + }, + "house_chart": [ + [], + [], + [], + [ + "Ketu" + ], + [ + "Sun", + "Moon" + ], + [ + "Saturn" + ], + [], + [], + [ + "Venus" + ], + [ + "Jupiter", + "Rahu" + ], + [], + [ + "Mars", + "Mercury" + ] + ] + }, + "D8_Ashtamsa": { + "division": 8, + "name": "Ashtamsa", + "meaning": "突发事件", + "ascendant": { + "sign": "Pisces", + "sign_index": 11, + "degree": 25.1952, + "absolute_degree": 355.1952 + }, + "planets": { + "Sun": { + "sign": "Gemini", + "sign_index": 2, + "degree": 12.2702, + "house": 4, + "absolute_degree": 72.2702 + }, + "Moon": { + "sign": "Gemini", + "sign_index": 2, + "degree": 29.1694, + "house": 4, + "absolute_degree": 89.1694 + }, + "Mars": { + "sign": "Pisces", + "sign_index": 11, + "degree": 28.1934, + "house": 1, + "absolute_degree": 358.1934 + }, + "Mercury": { + "sign": "Aries", + "sign_index": 0, + "degree": 12.7508, + "house": 2, + "absolute_degree": 12.7508 + }, + "Jupiter": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 8.3187, + "house": 10, + "absolute_degree": 248.3187 + }, + "Venus": { + "sign": "Libra", + "sign_index": 6, + "degree": 28.866, + "house": 8, + "absolute_degree": 208.866 + }, + "Saturn": { + "sign": "Virgo", + "sign_index": 5, + "degree": 14.1922, + "house": 7, + "absolute_degree": 164.1922 + }, + "Rahu": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 19.5049, + "house": 11, + "absolute_degree": 289.5049 + }, + "Ketu": { + "sign": "Cancer", + "sign_index": 3, + "degree": 19.5049, + "house": 5, + "absolute_degree": 109.5049 + } + }, + "house_chart": [ + [ + "Mars" + ], + [ + "Mercury" + ], + [], + [ + "Sun", + "Moon" + ], + [ + "Ketu" + ], + [], + [ + "Saturn" + ], + [ + "Venus" + ], + [], + [ + "Jupiter" + ], + [ + "Rahu" + ], + [] + ] + }, + "D9_Navamsa": { + "division": 9, + "name": "Navamsa", + "meaning": "配偶/灵性", + "ascendant": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 24.5946, + "absolute_degree": 264.5946 + }, + "planets": { + "Sun": { + "sign": "Libra", + "sign_index": 6, + "degree": 13.8039, + "house": 11, + "absolute_degree": 193.8039 + }, + "Moon": { + "sign": "Pisces", + "sign_index": 11, + "degree": 17.8156, + "house": 4, + "absolute_degree": 347.8156 + }, + "Mars": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 16.7176, + "house": 1, + "absolute_degree": 256.7176 + }, + "Mercury": { + "sign": "Aries", + "sign_index": 0, + "degree": 25.5947, + "house": 5, + "absolute_degree": 25.5947 + }, + "Jupiter": { + "sign": "Taurus", + "sign_index": 1, + "degree": 1.8585, + "house": 6, + "absolute_degree": 31.8585 + }, + "Venus": { + "sign": "Scorpio", + "sign_index": 7, + "degree": 24.9742, + "house": 12, + "absolute_degree": 234.9742 + }, + "Saturn": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 15.9663, + "house": 2, + "absolute_degree": 285.9663 + }, + "Rahu": { + "sign": "Gemini", + "sign_index": 2, + "degree": 6.9431, + "house": 7, + "absolute_degree": 66.9431 + }, + "Ketu": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 6.9431, + "house": 1, + "absolute_degree": 246.9431 + } + }, + "house_chart": [ + [ + "Mars", + "Ketu" + ], + [ + "Saturn" + ], + [], + [ + "Moon" + ], + [ + "Mercury" + ], + [ + "Jupiter" + ], + [ + "Rahu" + ], + [], + [], + [], + [ + "Sun" + ], + [ + "Venus" + ] + ] + }, + "D10_Dasamsa": { + "division": 10, + "name": "Dasamsa", + "meaning": "事业", + "ascendant": { + "sign": "Taurus", + "sign_index": 1, + "degree": 23.994, + "absolute_degree": 53.994 + }, + "planets": { + "Sun": { + "sign": "Gemini", + "sign_index": 2, + "degree": 15.3377, + "house": 2, + "absolute_degree": 75.3377 + }, + "Moon": { + "sign": "Leo", + "sign_index": 4, + "degree": 6.4617, + "house": 4, + "absolute_degree": 126.4617 + }, + "Mars": { + "sign": "Taurus", + "sign_index": 1, + "degree": 5.2418, + "house": 1, + "absolute_degree": 35.2418 + }, + "Mercury": { + "sign": "Taurus", + "sign_index": 1, + "degree": 8.4385, + "house": 1, + "absolute_degree": 38.4385 + }, + "Jupiter": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 25.3983, + "house": 9, + "absolute_degree": 295.3983 + }, + "Venus": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 21.0825, + "house": 8, + "absolute_degree": 261.0825 + }, + "Saturn": { + "sign": "Virgo", + "sign_index": 5, + "degree": 17.7403, + "house": 5, + "absolute_degree": 167.7403 + }, + "Rahu": { + "sign": "Aquarius", + "sign_index": 10, + "degree": 24.3812, + "house": 10, + "absolute_degree": 324.3812 + }, + "Ketu": { + "sign": "Leo", + "sign_index": 4, + "degree": 24.3812, + "house": 4, + "absolute_degree": 144.3812 + } + }, + "house_chart": [ + [ + "Mars", + "Mercury" + ], + [ + "Sun" + ], + [], + [ + "Moon", + "Ketu" + ], + [ + "Saturn" + ], + [], + [], + [ + "Venus" + ], + [ + "Jupiter" + ], + [ + "Rahu" + ], + [], + [] + ] + }, + "D11_Rudramsa": { + "division": 11, + "name": "Rudramsa", + "meaning": "破坏/转化", + "ascendant": { + "sign": "Gemini", + "sign_index": 2, + "degree": 23.3934, + "absolute_degree": 83.3934 + }, + "planets": { + "Sun": { + "sign": "Gemini", + "sign_index": 2, + "degree": 16.8715, + "house": 1, + "absolute_degree": 76.8715 + }, + "Moon": { + "sign": "Leo", + "sign_index": 4, + "degree": 25.1079, + "house": 3, + "absolute_degree": 145.1079 + }, + "Mars": { + "sign": "Taurus", + "sign_index": 1, + "degree": 23.766, + "house": 12, + "absolute_degree": 53.766 + }, + "Mercury": { + "sign": "Taurus", + "sign_index": 1, + "degree": 21.2824, + "house": 12, + "absolute_degree": 51.2824 + }, + "Jupiter": { + "sign": "Aquarius", + "sign_index": 10, + "degree": 18.9382, + "house": 9, + "absolute_degree": 318.9382 + }, + "Venus": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 17.1907, + "house": 8, + "absolute_degree": 287.1907 + }, + "Saturn": { + "sign": "Virgo", + "sign_index": 5, + "degree": 19.5143, + "house": 4, + "absolute_degree": 169.5143 + }, + "Rahu": { + "sign": "Pisces", + "sign_index": 11, + "degree": 11.8193, + "house": 10, + "absolute_degree": 341.8193 + }, + "Ketu": { + "sign": "Virgo", + "sign_index": 5, + "degree": 11.8193, + "house": 4, + "absolute_degree": 161.8193 + } + }, + "house_chart": [ + [ + "Sun" + ], + [], + [ + "Moon" + ], + [ + "Saturn", + "Ketu" + ], + [], + [], + [], + [ + "Venus" + ], + [ + "Jupiter" + ], + [ + "Rahu" + ], + [], + [ + "Mars", + "Mercury" + ] + ] + }, + "D12_Dwadasamsa": { + "division": 12, + "name": "Dwadasamsa", + "meaning": "父母", + "ascendant": { + "sign": "Cancer", + "sign_index": 3, + "degree": 22.7928, + "absolute_degree": 112.7928 + }, + "planets": { + "Sun": { + "sign": "Gemini", + "sign_index": 2, + "degree": 18.4053, + "house": 12, + "absolute_degree": 78.4053 + }, + "Moon": { + "sign": "Virgo", + "sign_index": 5, + "degree": 13.7541, + "house": 3, + "absolute_degree": 163.7541 + }, + "Mars": { + "sign": "Libra", + "sign_index": 6, + "degree": 12.2902, + "house": 4, + "absolute_degree": 192.2902 + }, + "Mercury": { + "sign": "Libra", + "sign_index": 6, + "degree": 4.1262, + "house": 4, + "absolute_degree": 184.1262 + }, + "Jupiter": { + "sign": "Pisces", + "sign_index": 11, + "degree": 12.478, + "house": 9, + "absolute_degree": 342.478 + }, + "Venus": { + "sign": "Aquarius", + "sign_index": 10, + "degree": 13.299, + "house": 8, + "absolute_degree": 313.299 + }, + "Saturn": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 21.2884, + "house": 7, + "absolute_degree": 291.2884 + }, + "Rahu": { + "sign": "Cancer", + "sign_index": 3, + "degree": 29.2574, + "house": 1, + "absolute_degree": 119.2574 + }, + "Ketu": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 29.2574, + "house": 7, + "absolute_degree": 299.2574 + } + }, + "house_chart": [ + [ + "Rahu" + ], + [], + [ + "Moon" + ], + [ + "Mars", + "Mercury" + ], + [], + [], + [ + "Saturn", + "Ketu" + ], + [ + "Venus" + ], + [ + "Jupiter" + ], + [], + [], + [ + "Sun" + ] + ] + }, + "D16_Shodasamsa": { + "division": 16, + "name": "Shodasamsa", + "meaning": "交通工具/舒适", + "ascendant": { + "sign": "Pisces", + "sign_index": 11, + "degree": 20.3904, + "absolute_degree": 350.3904 + }, + "planets": { + "Sun": { + "sign": "Aquarius", + "sign_index": 10, + "degree": 24.5404, + "house": 12, + "absolute_degree": 324.5404 + }, + "Moon": { + "sign": "Pisces", + "sign_index": 11, + "degree": 28.3388, + "house": 1, + "absolute_degree": 358.3388 + }, + "Mars": { + "sign": "Leo", + "sign_index": 4, + "degree": 26.3869, + "house": 6, + "absolute_degree": 146.3869 + }, + "Mercury": { + "sign": "Pisces", + "sign_index": 11, + "degree": 25.5016, + "house": 1, + "absolute_degree": 355.5016 + }, + "Jupiter": { + "sign": "Aquarius", + "sign_index": 10, + "degree": 16.6373, + "house": 12, + "absolute_degree": 316.6373 + }, + "Venus": { + "sign": "Taurus", + "sign_index": 1, + "degree": 27.7319, + "house": 3, + "absolute_degree": 57.7319 + }, + "Saturn": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 28.3845, + "house": 11, + "absolute_degree": 298.3845 + }, + "Rahu": { + "sign": "Libra", + "sign_index": 6, + "degree": 9.0099, + "house": 8, + "absolute_degree": 189.0099 + }, + "Ketu": { + "sign": "Aries", + "sign_index": 0, + "degree": 9.0099, + "house": 2, + "absolute_degree": 9.0099 + } + }, + "house_chart": [ + [ + "Moon", + "Mercury" + ], + [ + "Ketu" + ], + [ + "Venus" + ], + [], + [], + [ + "Mars" + ], + [], + [ + "Rahu" + ], + [], + [], + [ + "Saturn" + ], + [ + "Sun", + "Jupiter" + ] + ] + }, + "D20_Vimsamsa": { + "division": 20, + "name": "Vimsamsa", + "meaning": "灵性修行", + "ascendant": { + "sign": "Scorpio", + "sign_index": 7, + "degree": 17.988, + "absolute_degree": 227.988 + }, + "planets": { + "Sun": { + "sign": "Scorpio", + "sign_index": 7, + "degree": 0.6754, + "house": 1, + "absolute_degree": 210.6754 + }, + "Moon": { + "sign": "Libra", + "sign_index": 6, + "degree": 12.9235, + "house": 12, + "absolute_degree": 192.9235 + }, + "Mars": { + "sign": "Cancer", + "sign_index": 3, + "degree": 10.4836, + "house": 9, + "absolute_degree": 100.4836 + }, + "Mercury": { + "sign": "Virgo", + "sign_index": 5, + "degree": 16.8771, + "house": 11, + "absolute_degree": 166.8771 + }, + "Jupiter": { + "sign": "Capricorn", + "sign_index": 9, + "degree": 20.7967, + "house": 3, + "absolute_degree": 290.7967 + }, + "Venus": { + "sign": "Virgo", + "sign_index": 5, + "degree": 12.1649, + "house": 11, + "absolute_degree": 162.1649 + }, + "Saturn": { + "sign": "Aquarius", + "sign_index": 10, + "degree": 5.4806, + "house": 4, + "absolute_degree": 305.4806 + }, + "Rahu": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 18.7624, + "house": 2, + "absolute_degree": 258.7624 + }, + "Ketu": { + "sign": "Gemini", + "sign_index": 2, + "degree": 18.7624, + "house": 8, + "absolute_degree": 78.7624 + } + }, + "house_chart": [ + [ + "Sun" + ], + [ + "Rahu" + ], + [ + "Jupiter" + ], + [ + "Saturn" + ], + [], + [], + [], + [ + "Ketu" + ], + [ + "Mars" + ], + [], + [ + "Mercury", + "Venus" + ], + [ + "Moon" + ] + ] + }, + "D24_Chaturvimsamsa": { + "division": 24, + "name": "Chaturvimsamsa", + "meaning": "教育/学习", + "ascendant": { + "sign": "Cancer", + "sign_index": 3, + "degree": 15.5856, + "absolute_degree": 105.5856 + }, + "planets": { + "Sun": { + "sign": "Virgo", + "sign_index": 5, + "degree": 6.8105, + "house": 3, + "absolute_degree": 156.8105 + }, + "Moon": { + "sign": "Libra", + "sign_index": 6, + "degree": 27.5082, + "house": 4, + "absolute_degree": 207.5082 + }, + "Mars": { + "sign": "Virgo", + "sign_index": 5, + "degree": 24.5803, + "house": 3, + "absolute_degree": 174.5803 + }, + "Mercury": { + "sign": "Taurus", + "sign_index": 1, + "degree": 8.2525, + "house": 11, + "absolute_degree": 38.2525 + }, + "Jupiter": { + "sign": "Aquarius", + "sign_index": 10, + "degree": 24.956, + "house": 8, + "absolute_degree": 324.956 + }, + "Venus": { + "sign": "Aries", + "sign_index": 0, + "degree": 26.5979, + "house": 10, + "absolute_degree": 26.5979 + }, + "Saturn": { + "sign": "Leo", + "sign_index": 4, + "degree": 12.5767, + "house": 2, + "absolute_degree": 132.5767 + }, + "Rahu": { + "sign": "Leo", + "sign_index": 4, + "degree": 28.5148, + "house": 2, + "absolute_degree": 148.5148 + }, + "Ketu": { + "sign": "Leo", + "sign_index": 4, + "degree": 28.5148, + "house": 2, + "absolute_degree": 148.5148 + } + }, + "house_chart": [ + [], + [ + "Saturn", + "Rahu", + "Ketu" + ], + [ + "Sun", + "Mars" + ], + [ + "Moon" + ], + [], + [], + [], + [ + "Jupiter" + ], + [], + [ + "Venus" + ], + [ + "Mercury" + ], + [] + ] + }, + "D27_Bhamsa": { + "division": 27, + "name": "Bhamsa", + "meaning": "力量/弱点", + "ascendant": { + "sign": "Libra", + "sign_index": 6, + "degree": 13.7838, + "absolute_degree": 193.7838 + }, + "planets": { + "Sun": { + "sign": "Cancer", + "sign_index": 3, + "degree": 11.4118, + "house": 10, + "absolute_degree": 101.4118 + }, + "Moon": { + "sign": "Gemini", + "sign_index": 2, + "degree": 23.4467, + "house": 9, + "absolute_degree": 83.4467 + }, + "Mars": { + "sign": "Pisces", + "sign_index": 11, + "degree": 20.1529, + "house": 6, + "absolute_degree": 350.1529 + }, + "Mercury": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 16.784, + "house": 3, + "absolute_degree": 256.784 + }, + "Jupiter": { + "sign": "Pisces", + "sign_index": 11, + "degree": 5.5755, + "house": 6, + "absolute_degree": 335.5755 + }, + "Venus": { + "sign": "Pisces", + "sign_index": 11, + "degree": 14.9226, + "house": 6, + "absolute_degree": 344.9226 + }, + "Saturn": { + "sign": "Libra", + "sign_index": 6, + "degree": 17.8988, + "house": 1, + "absolute_degree": 197.8988 + }, + "Rahu": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 20.8292, + "house": 3, + "absolute_degree": 260.8292 + }, + "Ketu": { + "sign": "Gemini", + "sign_index": 2, + "degree": 20.8292, + "house": 9, + "absolute_degree": 80.8292 + } + }, + "house_chart": [ + [ + "Saturn" + ], + [], + [ + "Mercury", + "Rahu" + ], + [], + [], + [ + "Mars", + "Jupiter", + "Venus" + ], + [], + [], + [ + "Moon", + "Ketu" + ], + [ + "Sun" + ], + [], + [] + ] + }, + "D30_Trimsamsa": { + "division": 30, + "name": "Trimsamsa", + "meaning": "不幸/困难", + "ascendant": { + "sign": "Taurus", + "sign_index": 1, + "degree": 26.3964, + "absolute_degree": 56.3964 + }, + "planets": { + "Sun": { + "sign": "Aries", + "sign_index": 0, + "degree": 9.2026, + "house": 12, + "absolute_degree": 9.2026 + }, + "Moon": { + "sign": "Gemini", + "sign_index": 2, + "degree": 2.7695, + "house": 2, + "absolute_degree": 62.7695 + }, + "Mars": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 24.4657, + "house": 8, + "absolute_degree": 264.4657 + }, + "Mercury": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 3.1644, + "house": 8, + "absolute_degree": 243.1644 + }, + "Jupiter": { + "sign": "Gemini", + "sign_index": 2, + "degree": 23.7437, + "house": 2, + "absolute_degree": 83.7437 + }, + "Venus": { + "sign": "Taurus", + "sign_index": 1, + "degree": 6.6495, + "house": 1, + "absolute_degree": 36.6495 + }, + "Saturn": { + "sign": "Taurus", + "sign_index": 1, + "degree": 10.6442, + "house": 1, + "absolute_degree": 40.6442 + }, + "Rahu": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 20.3929, + "house": 8, + "absolute_degree": 260.3929 + }, + "Ketu": { + "sign": "Sagittarius", + "sign_index": 8, + "degree": 20.3929, + "house": 8, + "absolute_degree": 260.3929 + } + }, + "house_chart": [ + [ + "Venus", + "Saturn" + ], + [ + "Moon", + "Jupiter" + ], + [], + [], + [], + [], + [], + [ + "Mars", + "Mercury", + "Rahu", + "Ketu" + ], + [], + [], + [], + [ + "Sun" + ] + ] + } + } + }, + "chara": { + "success": true, + "endpoint": "chara_dasha", + "alias_of": "jaimini", + "result": { + "chara_dasha": { + "method": "Chara Dasha (kn_rao)", + "variant_profile": "kn_rao", + "variant_profile_status": "profiled", + "gender": null, + "ascendant": "Leo", + "ascendant_idx": 4, + "progression_source": "9th_house_direction", + "dasha_sequence": [ + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "lord_in_sign": "Gemini", + "lord_in_sign_idx": 2, + "duration_years": 2, + "dignity_adjustment": "none", + "order": 1, + "antardashas": [ + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "duration_years": 0.167, + "order": 1 + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "duration_years": 0.167, + "order": 2 + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Mars", + "duration_years": 0.167, + "order": 3 + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "duration_years": 0.167, + "order": 4 + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "duration_years": 0.167, + "order": 5 + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "duration_years": 0.167, + "order": 6 + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "duration_years": 0.167, + "order": 7 + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "duration_years": 0.167, + "order": 8 + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "duration_years": 0.167, + "order": 9 + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "duration_years": 0.167, + "order": 10 + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "duration_years": 0.167, + "order": 11 + }, + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "duration_years": 0.167, + "order": 12 + } + ] + }, + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "lord_in_sign": "Taurus", + "lord_in_sign_idx": 1, + "duration_years": 4, + "dignity_adjustment": "none", + "order": 2, + "antardashas": [ + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "duration_years": 0.333, + "order": 1 + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "duration_years": 0.333, + "order": 2 + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Mars", + "duration_years": 0.333, + "order": 3 + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "duration_years": 0.333, + "order": 4 + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "duration_years": 0.333, + "order": 5 + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "duration_years": 0.333, + "order": 6 + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "duration_years": 0.333, + "order": 7 + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "duration_years": 0.333, + "order": 8 + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "duration_years": 0.333, + "order": 9 + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "duration_years": 0.333, + "order": 10 + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "duration_years": 0.333, + "order": 11 + }, + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "duration_years": 0.333, + "order": 12 + } + ] + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "lord_in_sign": "Aries", + "lord_in_sign_idx": 0, + "duration_years": 6, + "dignity_adjustment": "none", + "order": 3, + "antardashas": [ + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "duration_years": 0.5, + "order": 1 + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "duration_years": 0.5, + "order": 2 + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Mars", + "duration_years": 0.5, + "order": 3 + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "duration_years": 0.5, + "order": 4 + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "duration_years": 0.5, + "order": 5 + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "duration_years": 0.5, + "order": 6 + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "duration_years": 0.5, + "order": 7 + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "duration_years": 0.5, + "order": 8 + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "duration_years": 0.5, + "order": 9 + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "duration_years": 0.5, + "order": 10 + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "duration_years": 0.5, + "order": 11 + }, + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "duration_years": 0.5, + "order": 12 + } + ] + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Ketu", + "lord_in_sign": "Cancer", + "lord_in_sign_idx": 3, + "duration_years": 8, + "dignity_adjustment": "none", + "order": 4, + "antardashas": [ + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "duration_years": 0.667, + "order": 1 + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "duration_years": 0.667, + "order": 2 + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Mars", + "duration_years": 0.667, + "order": 3 + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "duration_years": 0.667, + "order": 4 + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "duration_years": 0.667, + "order": 5 + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "duration_years": 0.667, + "order": 6 + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "duration_years": 0.667, + "order": 7 + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "duration_years": 0.667, + "order": 8 + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "duration_years": 0.667, + "order": 9 + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "duration_years": 0.667, + "order": 10 + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "duration_years": 0.667, + "order": 11 + }, + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "duration_years": 0.667, + "order": 12 + } + ] + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "lord_in_sign": "Gemini", + "lord_in_sign_idx": 2, + "duration_years": 6, + "dignity_adjustment": "none", + "order": 5, + "antardashas": [ + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "duration_years": 0.5, + "order": 1 + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "duration_years": 0.5, + "order": 2 + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Mars", + "duration_years": 0.5, + "order": 3 + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "duration_years": 0.5, + "order": 4 + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "duration_years": 0.5, + "order": 5 + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "duration_years": 0.5, + "order": 6 + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "duration_years": 0.5, + "order": 7 + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "duration_years": 0.5, + "order": 8 + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "duration_years": 0.5, + "order": 9 + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "duration_years": 0.5, + "order": 10 + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "duration_years": 0.5, + "order": 11 + }, + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "duration_years": 0.5, + "order": 12 + } + ] + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "lord_in_sign": "Capricorn", + "lord_in_sign_idx": 9, + "duration_years": 12, + "dignity_adjustment": "none", + "order": 6, + "antardashas": [ + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "duration_years": 1.0, + "order": 1 + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "duration_years": 1.0, + "order": 2 + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Mars", + "duration_years": 1.0, + "order": 3 + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "duration_years": 1.0, + "order": 4 + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "duration_years": 1.0, + "order": 5 + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "duration_years": 1.0, + "order": 6 + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "duration_years": 1.0, + "order": 7 + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "duration_years": 1.0, + "order": 8 + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "duration_years": 1.0, + "order": 9 + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "duration_years": 1.0, + "order": 10 + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "duration_years": 1.0, + "order": 11 + }, + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "duration_years": 1.0, + "order": 12 + } + ] + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "lord_in_sign": "Capricorn", + "lord_in_sign_idx": 9, + "duration_years": 1, + "dignity_adjustment": "none", + "order": 7, + "antardashas": [ + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "duration_years": 0.083, + "order": 1 + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "duration_years": 0.083, + "order": 2 + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Mars", + "duration_years": 0.083, + "order": 3 + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "duration_years": 0.083, + "order": 4 + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "duration_years": 0.083, + "order": 5 + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "duration_years": 0.083, + "order": 6 + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "duration_years": 0.083, + "order": 7 + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "duration_years": 0.083, + "order": 8 + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "duration_years": 0.083, + "order": 9 + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "duration_years": 0.083, + "order": 10 + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "duration_years": 0.083, + "order": 11 + }, + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "duration_years": 0.083, + "order": 12 + } + ] + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "lord_in_sign": "Gemini", + "lord_in_sign_idx": 2, + "duration_years": 9, + "dignity_adjustment": "none", + "order": 8, + "antardashas": [ + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "duration_years": 0.75, + "order": 1 + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "duration_years": 0.75, + "order": 2 + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Mars", + "duration_years": 0.75, + "order": 3 + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "duration_years": 0.75, + "order": 4 + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "duration_years": 0.75, + "order": 5 + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "duration_years": 0.75, + "order": 6 + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "duration_years": 0.75, + "order": 7 + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "duration_years": 0.75, + "order": 8 + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "duration_years": 0.75, + "order": 9 + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "duration_years": 0.75, + "order": 10 + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "duration_years": 0.75, + "order": 11 + }, + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "duration_years": 0.75, + "order": 12 + } + ] + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "lord_in_sign": "Pisces", + "lord_in_sign_idx": 11, + "duration_years": 11, + "dignity_adjustment": "none", + "order": 9, + "antardashas": [ + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "duration_years": 0.917, + "order": 1 + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "duration_years": 0.917, + "order": 2 + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Mars", + "duration_years": 0.917, + "order": 3 + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "duration_years": 0.917, + "order": 4 + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "duration_years": 0.917, + "order": 5 + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "duration_years": 0.917, + "order": 6 + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "duration_years": 0.917, + "order": 7 + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "duration_years": 0.917, + "order": 8 + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "duration_years": 0.917, + "order": 9 + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "duration_years": 0.917, + "order": 10 + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "duration_years": 0.917, + "order": 11 + }, + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "duration_years": 0.917, + "order": 12 + } + ] + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "lord_in_sign": "Aries", + "lord_in_sign_idx": 0, + "duration_years": 11, + "dignity_adjustment": "none", + "order": 10, + "antardashas": [ + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "duration_years": 0.917, + "order": 1 + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "duration_years": 0.917, + "order": 2 + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Mars", + "duration_years": 0.917, + "order": 3 + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "duration_years": 0.917, + "order": 4 + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "duration_years": 0.917, + "order": 5 + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "duration_years": 0.917, + "order": 6 + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "duration_years": 0.917, + "order": 7 + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "duration_years": 0.917, + "order": 8 + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "duration_years": 0.917, + "order": 9 + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "duration_years": 0.917, + "order": 10 + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "duration_years": 0.917, + "order": 11 + }, + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "duration_years": 0.917, + "order": 12 + } + ] + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "lord_in_sign": "Taurus", + "lord_in_sign_idx": 1, + "duration_years": 11, + "dignity_adjustment": "none", + "order": 11, + "antardashas": [ + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "duration_years": 0.917, + "order": 1 + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "duration_years": 0.917, + "order": 2 + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Mars", + "duration_years": 0.917, + "order": 3 + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "duration_years": 0.917, + "order": 4 + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "duration_years": 0.917, + "order": 5 + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "duration_years": 0.917, + "order": 6 + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "duration_years": 0.917, + "order": 7 + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "duration_years": 0.917, + "order": 8 + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "duration_years": 0.917, + "order": 9 + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "duration_years": 0.917, + "order": 10 + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "duration_years": 0.917, + "order": 11 + }, + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "duration_years": 0.917, + "order": 12 + } + ] + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "lord_in_sign": "Aquarius", + "lord_in_sign_idx": 10, + "duration_years": 5, + "dignity_adjustment": "none", + "order": 12, + "antardashas": [ + { + "sign": "Virgo", + "sign_idx": 5, + "lord": "Mercury", + "duration_years": 0.417, + "order": 1 + }, + { + "sign": "Libra", + "sign_idx": 6, + "lord": "Venus", + "duration_years": 0.417, + "order": 2 + }, + { + "sign": "Scorpio", + "sign_idx": 7, + "lord": "Mars", + "duration_years": 0.417, + "order": 3 + }, + { + "sign": "Sagittarius", + "sign_idx": 8, + "lord": "Jupiter", + "duration_years": 0.417, + "order": 4 + }, + { + "sign": "Capricorn", + "sign_idx": 9, + "lord": "Saturn", + "duration_years": 0.417, + "order": 5 + }, + { + "sign": "Aquarius", + "sign_idx": 10, + "lord": "Saturn", + "duration_years": 0.417, + "order": 6 + }, + { + "sign": "Pisces", + "sign_idx": 11, + "lord": "Jupiter", + "duration_years": 0.417, + "order": 7 + }, + { + "sign": "Aries", + "sign_idx": 0, + "lord": "Mars", + "duration_years": 0.417, + "order": 8 + }, + { + "sign": "Taurus", + "sign_idx": 1, + "lord": "Venus", + "duration_years": 0.417, + "order": 9 + }, + { + "sign": "Gemini", + "sign_idx": 2, + "lord": "Mercury", + "duration_years": 0.417, + "order": 10 + }, + { + "sign": "Cancer", + "sign_idx": 3, + "lord": "Moon", + "duration_years": 0.417, + "order": 11 + }, + { + "sign": "Leo", + "sign_idx": 4, + "lord": "Sun", + "duration_years": 0.417, + "order": 12 + } + ] + } + ], + "total_cycle_years": 86, + "capability_status": "covered", + "has_antardasha": true, + "has_pratyantar": true, + "antardasha_method": "equal_division_12 (PyJHora method=2)" + } + } + }, + "western": { + "source_engine": "pyswisseph_tropical", + "engine_version": "2.10.03", + "zodiac": "tropical", + "house_system": "P", + "calculation_contract": { + "birth_timezone": "UTC+8", + "local_birth_time": "1990-06-15T12:00:00+08:00", + "utc_birth_time": "1990-06-15T04:00:00+00:00", + "julian_day_ut": 2448057.66666667, + "latitude": 39.9042, + "longitude": 116.4074, + "ephemeris": "Swiss Ephemeris via pyswisseph" + }, + "natal": { + "ascendant": { + "longitude": 171.676792, + "sign": "Virgo", + "degree_in_sign": 21.676792 + }, + "mc": { + "longitude": 80.432995, + "sign": "Gemini", + "degree_in_sign": 20.432995 + }, + "angles": { + "ascendant": { + "longitude": 171.676792, + "sign": "Virgo", + "degree_in_sign": 21.676792 + }, + "mc": { + "longitude": 80.432995, + "sign": "Gemini", + "degree_in_sign": 20.432995 + }, + "descendant": { + "longitude": 351.676792, + "sign": "Pisces", + "degree_in_sign": 21.676792 + }, + "ic": { + "longitude": 260.432995, + "sign": "Sagittarius", + "degree_in_sign": 20.432995 + } + }, + "planets": { + "sun": { + "longitude": 83.811194, + "sign": "Gemini", + "degree_in_sign": 23.811194, + "house": 10, + "speed_longitude": 0.95513496, + "retrograde": false + }, + "moon": { + "longitude": 340.923597, + "sign": "Pisces", + "degree_in_sign": 10.923597, + "house": 6, + "speed_longitude": 13.24504895, + "retrograde": false + }, + "mercury": { + "longitude": 65.121274, + "sign": "Gemini", + "degree_in_sign": 5.121274, + "house": 9, + "speed_longitude": 1.70063552, + "retrograde": false + }, + "venus": { + "longitude": 48.385668, + "sign": "Taurus", + "degree_in_sign": 18.385668, + "house": 9, + "speed_longitude": 1.17567857, + "retrograde": false + }, + "mars": { + "longitude": 10.801602, + "sign": "Aries", + "degree_in_sign": 10.801602, + "house": 7, + "speed_longitude": 0.71931871, + "retrograde": false + }, + "jupiter": { + "longitude": 105.817255, + "sign": "Cancer", + "degree_in_sign": 15.817255, + "house": 10, + "speed_longitude": 0.21645353, + "retrograde": false + }, + "saturn": { + "longitude": 294.051451, + "sign": "Capricorn", + "degree_in_sign": 24.051451, + "house": 5, + "speed_longitude": -0.05829555, + "retrograde": true + }, + "uranus": { + "longitude": 278.178202, + "sign": "Capricorn", + "degree_in_sign": 8.178202, + "house": 4, + "speed_longitude": -0.03884501, + "retrograde": true + }, + "neptune": { + "longitude": 283.725428, + "sign": "Capricorn", + "degree_in_sign": 13.725428, + "house": 4, + "speed_longitude": -0.02495636, + "retrograde": true + }, + "pluto": { + "longitude": 225.408298, + "sign": "Scorpio", + "degree_in_sign": 15.408298, + "house": 2, + "speed_longitude": -0.02030208, + "retrograde": true + }, + "true_node": { + "longitude": 308.11454, + "sign": "Aquarius", + "degree_in_sign": 8.11454, + "house": 5, + "speed_longitude": 0.01798365, + "retrograde": false + } + }, + "houses": [ + { + "house": 1, + "cusp": { + "longitude": 171.676792, + "sign": "Virgo", + "degree_in_sign": 21.676792 + } + }, + { + "house": 2, + "cusp": { + "longitude": 197.152555, + "sign": "Libra", + "degree_in_sign": 17.152555 + } + }, + { + "house": 3, + "cusp": { + "longitude": 227.133453, + "sign": "Scorpio", + "degree_in_sign": 17.133453 + } + }, + { + "house": 4, + "cusp": { + "longitude": 260.432995, + "sign": "Sagittarius", + "degree_in_sign": 20.432995 + } + }, + { + "house": 5, + "cusp": { + "longitude": 294.045244, + "sign": "Capricorn", + "degree_in_sign": 24.045244 + } + }, + { + "house": 6, + "cusp": { + "longitude": 324.865331, + "sign": "Aquarius", + "degree_in_sign": 24.865331 + } + }, + { + "house": 7, + "cusp": { + "longitude": 351.676792, + "sign": "Pisces", + "degree_in_sign": 21.676792 + } + }, + { + "house": 8, + "cusp": { + "longitude": 17.152555, + "sign": "Aries", + "degree_in_sign": 17.152555 + } + }, + { + "house": 9, + "cusp": { + "longitude": 47.133453, + "sign": "Taurus", + "degree_in_sign": 17.133453 + } + }, + { + "house": 10, + "cusp": { + "longitude": 80.432995, + "sign": "Gemini", + "degree_in_sign": 20.432995 + } + }, + { + "house": 11, + "cusp": { + "longitude": 114.045244, + "sign": "Cancer", + "degree_in_sign": 24.045244 + } + }, + { + "house": 12, + "cusp": { + "longitude": 144.865331, + "sign": "Leo", + "degree_in_sign": 24.865331 + } + } + ], + "aspects": [ + { + "left": "jupiter", + "right": "pluto", + "aspect": "trine", + "exact_degrees": 120.0, + "separation": 119.591043, + "orb": 0.408957, + "allowed_orb": 6.0 + }, + { + "left": "ascendant", + "right": "mc", + "aspect": "square", + "exact_degrees": 90.0, + "separation": 91.243797, + "orb": 1.243797, + "allowed_orb": 5.0 + }, + { + "left": "neptune", + "right": "pluto", + "aspect": "sextile", + "exact_degrees": 60.0, + "separation": 58.31713, + "orb": 1.68287, + "allowed_orb": 6.0 + }, + { + "left": "jupiter", + "right": "neptune", + "aspect": "opposition", + "exact_degrees": 180.0, + "separation": 177.908173, + "orb": 2.091827, + "allowed_orb": 6.0 + }, + { + "left": "sun", + "right": "ascendant", + "aspect": "square", + "exact_degrees": 90.0, + "separation": 87.865598, + "orb": 2.134402, + "allowed_orb": 5.0 + }, + { + "left": "saturn", + "right": "ascendant", + "aspect": "trine", + "exact_degrees": 120.0, + "separation": 122.374659, + "orb": 2.374659, + "allowed_orb": 5.0 + }, + { + "left": "venus", + "right": "jupiter", + "aspect": "sextile", + "exact_degrees": 60.0, + "separation": 57.431587, + "orb": 2.568413, + "allowed_orb": 6.0 + }, + { + "left": "mars", + "right": "uranus", + "aspect": "square", + "exact_degrees": 90.0, + "separation": 92.6234, + "orb": 2.6234, + "allowed_orb": 6.0 + }, + { + "left": "mars", + "right": "true_node", + "aspect": "sextile", + "exact_degrees": 60.0, + "separation": 62.687062, + "orb": 2.687062, + "allowed_orb": 6.0 + }, + { + "left": "moon", + "right": "uranus", + "aspect": "sextile", + "exact_degrees": 60.0, + "separation": 62.745395, + "orb": 2.745395, + "allowed_orb": 6.0 + }, + { + "left": "moon", + "right": "neptune", + "aspect": "sextile", + "exact_degrees": 60.0, + "separation": 57.198169, + "orb": 2.801831, + "allowed_orb": 6.0 + }, + { + "left": "mars", + "right": "neptune", + "aspect": "square", + "exact_degrees": 90.0, + "separation": 87.076174, + "orb": 2.923826, + "allowed_orb": 6.0 + }, + { + "left": "venus", + "right": "pluto", + "aspect": "opposition", + "exact_degrees": 180.0, + "separation": 177.02263, + "orb": 2.97737, + "allowed_orb": 6.0 + }, + { + "left": "mercury", + "right": "true_node", + "aspect": "trine", + "exact_degrees": 120.0, + "separation": 117.006734, + "orb": 2.993266, + "allowed_orb": 6.0 + }, + { + "left": "venus", + "right": "ascendant", + "aspect": "trine", + "exact_degrees": 120.0, + "separation": 123.291124, + "orb": 3.291124, + "allowed_orb": 5.0 + }, + { + "left": "sun", + "right": "mc", + "aspect": "conjunction", + "exact_degrees": 0.0, + "separation": 3.378199, + "orb": 3.378199, + "allowed_orb": 5.0 + }, + { + "left": "moon", + "right": "pluto", + "aspect": "trine", + "exact_degrees": 120.0, + "separation": 115.515299, + "orb": 4.484701, + "allowed_orb": 6.0 + }, + { + "left": "venus", + "right": "neptune", + "aspect": "trine", + "exact_degrees": 120.0, + "separation": 124.66024, + "orb": 4.66024, + "allowed_orb": 6.0 + }, + { + "left": "moon", + "right": "jupiter", + "aspect": "trine", + "exact_degrees": 120.0, + "separation": 124.893658, + "orb": 4.893658, + "allowed_orb": 6.0 + }, + { + "left": "mars", + "right": "jupiter", + "aspect": "square", + "exact_degrees": 90.0, + "separation": 95.015653, + "orb": 5.015653, + "allowed_orb": 6.0 + }, + { + "left": "uranus", + "right": "neptune", + "aspect": "conjunction", + "exact_degrees": 0.0, + "separation": 5.547226, + "orb": 5.547226, + "allowed_orb": 6.0 + }, + { + "left": "venus", + "right": "saturn", + "aspect": "trine", + "exact_degrees": 120.0, + "separation": 114.334217, + "orb": 5.665783, + "allowed_orb": 6.0 + }, + { + "left": "mercury", + "right": "mars", + "aspect": "sextile", + "exact_degrees": 60.0, + "separation": 54.319672, + "orb": 5.680328, + "allowed_orb": 6.0 + }, + { + "left": "moon", + "right": "mercury", + "aspect": "square", + "exact_degrees": 90.0, + "separation": 84.197677, + "orb": 5.802323, + "allowed_orb": 6.0 + } + ], + "distribution": { + "elements": { + "fire": 1, + "earth": 4, + "air": 3, + "water": 3 + }, + "modes": { + "cardinal": 5, + "fixed": 3, + "mutable": 3 + } + }, + "house_ruler_chains": { + "1": [ + "mercury" + ], + "2": [ + "venus" + ], + "3": [ + "mars" + ], + "4": [ + "jupiter", + "moon" + ], + "5": [ + "saturn" + ], + "6": [ + "saturn" + ], + "7": [ + "jupiter", + "moon" + ], + "8": [ + "mars" + ], + "9": [ + "venus" + ], + "10": [ + "mercury" + ], + "11": [ + "moon", + "jupiter" + ], + "12": [ + "sun", + "mercury" + ] + } + }, + "boundary": "Natal tropical calculation only; it does not calculate transits, progressions, solar arcs, returns, or interpretation." + } +} \ No newline at end of file diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index 73b6e7bc..c085779e 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -1591,6 +1591,7 @@ def test_capability_audit_scans_registry_and_local_sources() -> None: 'admin/subscriptions', 'admin/usage', 'admin/users', + 'chart', 'home', 'login', 'reports',