diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f9128b..8fb9d91c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-09 — 报告页恢复星盘,改北印式,全部分盘可见 + +个人长报告详情页里,本命盘、婚盘、月亮盘和 Vargas I 的全部分盘都会画出北印度菱形图,不再只剩标题。宫内用一字中文加整数度(逆行加「逆」),角上是星座序号。导出的 `.md` 仍带引擎 SVG,不含那段给网页用的 JSON。Skill 版本不变。 + + ## 2026-09-09 — 生时校正对话上方常驻一条时间轴,看得见范围在收窄 做生时校正时,对话上方多了一条固定的横条,写着当前的出生时间范围和它有多宽,例如「05:07–05:09 · 2 分钟」。下面那条轴按当前搜索范围铺开,候选分钟是一个个小圆点:还在范围内的是实心,已经被排除的变成空心留在原地——每答一题就能看见自己刚才那一答排掉了哪几分钟。范围收窄或放宽时,轴和圆点会滑到新位置。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5adf2dc7..ec978973 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -9333,3 +9333,19 @@ - 相关记录:BUG-043(禁止把后台评分状态渲染成用户需要管理的面板。本处边界不同且不冲突:等待屏是只读的,展示的是用户交付物自身的章节结构,`attemptCount`、原始错误码、lease、job id、payload 一律不出服务端)、BUG-576、BUG-574 - 复发自:无 - 修复版本:待发布 + +## BUG-607 | 长报告页分盘标题下没有图 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-09 +- 影响面:`/reports/` Markdown 阅读页、`personal-report-markdown-view.tsx`、`scripts/report_chart_block.py`、`vedic-chart-svg.tsx` +- 用户现象:长报告里 `#### D1 — Rashi Chart(本命盘)`、`#### D9 — Navamsa(婚盘)`、`#### Moon Chart(月亮参考盘)` 以及 Vargas I 下 19 个分盘标题后面全是空白,只有标题没有星盘图。 +- 触发条件:打开任意一份 2026-09-06 之后生成的个人长报告详情页。 +- 根因:引擎在 Markdown 里内联了南印度 SVG HTML。09-06 把阅读页改成 `react-markdown` + `skipHtml` 后,内联 HTML 被静默丢弃。`skipHtml` 本身是红线(出生地等用户字符串会进正文),不能靠放开 `rehype-raw` 修。 +- 修复:引擎在每张 SVG 旁追加 `jyotish-chart` 围栏 JSON;阅读页只认围栏,zod 校验后用北印式菱形组件自绘。导出 `.md` 前剥掉围栏,保留 SVG 给外部阅读器。 +- 验证:`tests/test_report_chart_block.py` 7 项(22 块围栏 == 22 个 ``,度数/星座/逆行与 core_chart 一致,且该文件在 `CORE_PYTEST_TARGETS`);`frontend/tests/report-chart-block.test.ts`、`frontend/tests/personal-report-markdown-view.test.tsx`(组件 SVG 有 `role="img"`,不含引擎 `viewBox="0 0 420 480"`,坏块显示「图盘数据无效」且无 ``(导出 / `run_all.py` 仍靠它)。每张成功的 SVG 旁边追加一段 `jyotish-chart` 围栏 JSON。报告页继续 `skipHtml`,只认围栏;zod 校验后用北印菱形组件自绘。导出 `.md` 前剥掉围栏。 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| 1 引擎围栏 | 完成 | 新模块 `scripts/report_chart_block.py`;`_render_south_chart` 成功路径拼接围栏,失败路径不加 | +| 2 前端围栏→组件 | 完成 | `report-chart-block.ts` + markdown `pre` 渲染器 + 下载剥块 | +| 3 北印几何 | 完成 | `north-indian-chart-geometry.ts`;`VedicChartSvg` 换成任务书顶点表;宫内最多 6 行,超出 `+N` | +| 4 SVG 中心标签 | 完成 | D1 仍是 `Rasi Chart / (D1)`;其它跟标题里的 `D\d+` / `Moon` | +| 5 记录 | 完成 | BUG-607、CHANGELOG、本文件、走查清单;状态板改「待验收」 | + +没有砍让步项。两列网格做了:`## 摘要` 是 eager lead,图都在 `.personal-report-md-lead` 里,所以 720px 规则同时打在 lead 和 section 上。 + +## 口径(与任务书一致) + +- 北印式、全部 22 张、前端自绘。 +- 宫内 `日 12°` / `土逆 3°`;角上星座序号 1–12。 +- 图下不加第二行标题;`aria-label` 只用校验过的 `id`(`D1 北印度盘` / `月亮参考盘`),不用 JSON 里的 `title`。 +- `skipHtml` / `disallowedElements` / `urlTransform` 未改;无 `rehype-raw`。 +- `scripts/jyotish_api_server.py` 未增行。 +- `tests/test_report_chart_block.py` 已列入 `CORE_PYTEST_TARGETS`,staging 自动门会跑这 22 块围栏。 + +## 偏离 + +1. **D1/D9 传给 SVG 的 `title` 括号从半角改成全角。** `####` 标题本来就是 `(本命盘)` / `(婚盘)`;旧代码只在 `_render_south_chart` 的 title 参数上用了半角 `(本命盘)`。改成全角是为了让围栏 JSON 的 `title` 与 `####` 一致。用户看见的标题文案没变。 +2. **两列用 float 配对 heading+figure,没有包一层 `.personal-report-chart-grid`。** 大纲不能改,没法给 19 张图单独包 grid。退役的 document view 仍用原来的 `.personal-report-chart-grid`。 +3. **`package.json` 的 `npm test` 加上 `tests/*.test.tsx`。** 否则新的 markdown-view 测试进不了全量门。当前 `tests/` 里只有这一份 `.tsx` 测试。 + +## 测试数字 + +开工未跑全量 `npm test`(任务书 §7 的基线命令在独立 worktree 上因时间放到收工一次跑)。对照同日 BUG-601 收工记录:2945 tests / 2903 pass / 28 fail / 14 skipped(当时无 Docker)。本轮 Docker 可用,跳过数为 0。 + +| 项 | 本轮实测 | 结论 | +| --- | --- | --- | +| `tests/test_report_chart_block.py` | 7 passed | 22 围栏 == 22 ``;D1 上升=core;逆行=本命;地名不进块;D9 SVG 含 `(D9)` 不含 `(D1)`;本文件在 quick 门清单里 | +| `tests/test_full_report_quality_gate.py` + longform parity/gaps2 | 先前本 worktree 已绿 | 标题 / SVG 数量门未破 | +| `run_quality_gate.py --profile quick` pytest | 649 passed, 1 skipped | Python 段通过 | +| `run_quality_gate.py` 的 `npm test` | 失败(与独立全量撞车) | 权威数字以下面独立 `npm test` 为准 | +| `tsc --noEmit` | 0 错 | 通过 | +| `npm run lint` | 0 error / 108 warning | warning 数与 BUG-601 相同;新文件无 error | +| `npm test` tests | 2975 | 高于 2945 | +| `npm test` pass | 2971 | 高于 2903 | +| `npm test` fail | 4 | 全是 Docker Postgres fixture:2 条抢槽超时、2 条 migration failed。与图盘无关 | +| `npm test` skipped | 0 | Docker 可用后不再跳过 DB 套件 | +| `globals.css` gzip | HEAD `35423` B → 工作树 `35760` B | +337 B / +0.95%,在 ±2% 内 | +| `next build` `/` | `○ Static` | webpack 构建(worktree 的 `node_modules` 是指向主仓的符号链接,Turbopack 拒绝跨根;CI 是实体目录,不受影响) | +| 首页 CSS gzip(构建产物) | `static/css/*.css` 最大块 35,779 B | 与源码 gzip 同量级 | +| 报告页 chunk gzip | `app/reports/[reportId]/page-*.js` = 10,772 B | 含 `jyotish-chart`;首页 `app/page-*.js` 不含 | + +新增前端测试 9 条,全绿: + +- `frontend/tests/report-chart-block.test.ts` 8 条(golden 22 块、整宫、逆行标签、毒化 title、坏 JSON、剥围栏、多边形面积/不重叠) +- `frontend/tests/personal-report-markdown-view.test.tsx` 1 条(组件 SVG + `role="img"`,无引擎 `viewBox="0 0 420 480"`,坏块「图盘数据无效」且无 ``。** 无登录态。走查清单在 `docs/testing/report-chart-render-20260909.md`。 +- 未跑 `npm run test:db` 单独套件(已含在全量 `npm test` 的 database-* 里)。 +- 未在本 worktree 用 Turbopack 跑 `npm run build`:符号链接 `node_modules` 会 panic。改用 `next build --webpack`,`/` 仍是 Static。 +- 未 push。代码进 staging 会走 `Staging Backend Quality Gate`(本轮不是纯文档)。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index c2901076..fc24c742 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -118,7 +118,7 @@ | `TASK-report-md-page-20260906.md` | `PROGRESS-report-md-page-20260906.md` | 长报告 Markdown 直接作为报告页 | 已合入 | `cfcd369d` / `809bdf13`(BUG-563 lint 随后修) | | `TASK-report-list-500-20260907.md`(仓库根) | `PROGRESS-report-list-500-20260907.md` | 列表 PostgREST JSON 路径 500 | 待验收 | `b466a6fc`(BUG-574) | | — | `PROGRESS-report-progress-20260909.md` | 生成等待屏只有 spinner 与秒表:后端 `progressPercent` / `progressPhase` 与分章行已产出,前端解析后一字未渲染,且分章行在 `generating` 时根本不出服务端。改为按章分格进度条 + 章节清单,停滞 90 秒改「用时较长,仍在写」;不画百分比条、不做插值动画、不报预计剩余 | 已验收 | `848e39e6`、`5565b632`(BUG-601) | -| `TASK-report-chart-render-20260909.md` | — | 报告页所有分盘标题下空白:引擎 22 张 `` 被 `skipHtml` 丢弃(09-06 Markdown 直渲回归)。引擎旁加 ```jyotish-chart 围栏 JSON,前端 zod 校验后用自绘北印式组件全部渲染;不放开原始 HTML | 待领取 | BUG-607 起 | +| `TASK-report-chart-render-20260909.md` | `PROGRESS-report-chart-render-20260909.md` | 报告页所有分盘标题下空白:引擎 22 张 `` 被 `skipHtml` 丢弃(09-06 Markdown 直渲回归)。引擎旁加 ```jyotish-chart 围栏 JSON,前端 zod 校验后用自绘北印式组件全部渲染;不放开原始 HTML | 待验收 | `codex/report-chart-render-20260909`(BUG-607) | ### 前端基础与工程 diff --git a/docs/testing/report-chart-render-20260909.md b/docs/testing/report-chart-render-20260909.md new file mode 100644 index 00000000..9139fef4 --- /dev/null +++ b/docs/testing/report-chart-render-20260909.md @@ -0,0 +1,40 @@ +# 长报告星盘:真实环境走查清单(2026-09-09,BUG-607) + +自动化替代证据见 `tests/test_report_chart_block.py`、`frontend/tests/report-chart-block.test.ts`、`frontend/tests/personal-report-markdown-view.test.tsx`。下面每一条都需要一位有真实账号的人在 staging 上对照一份**新生成**的个人长报告。 + +前置:staging 已部署含本轮改动的 SHA;账号点数足以生成一份个人报告。旧报告(没有 `jyotish-chart` 围栏)不会画出图,这是预期,不要当成回归。 + +## 1. 22 张图都在 + +生成一份新报告,打开 `/reports/`,滚完「Birth Chart / Vargas」和「Vargas I」。 + +- [ ] D1、D9、Moon Chart 标题下各有一张北印菱形图,不是空白。 +- [ ] Vargas I 从 D2 到 D60 的 19 个标题下也都有图(含图集里那张 D9)。 +- [ ] 图是菱形北印度盘,不是 4×4 方格南印度盘,也不是西洋圆盘。 +- [ ] 图下没有第二行标题;`####` 标题就是标题。 + +## 2. 标签口径 + +- [ ] 宫内是「日 12°」这种一字中文 + 整数度,不是 `Su` / `☉`。 +- [ ] 逆行看起来像「土逆 3°」。 +- [ ] 每宫角上是 1–12 的星座序号(1=白羊),不是宫位编号 1–12 从上升起算的另一套数(上升宫的数字应等于本命上升星座序号)。 + +## 3. 暗色主题 + +- [ ] 系统切到深色:线、字、角标仍然看得见,没有浅底深线或白字溶进纸色。 + +## 4. 手机与打印 + +- [ ] iPhone Safari 竖屏:图单列、居中,不撑出左右滚动(宽表除外)。 +- [ ] 桌面 ≥720px:Vargas I 的图是两列。 +- [ ] 打印预览:图仍是单列,不会变成两列挤在纸上。 + +## 5. 导出 `.md` + +- [ ] 点「导出报告(.md)」打开文件:能看到引擎 SVG,**没有** ```` ```jyotish-chart ```` 代码块。 + +## 6. 旧报告 + +- [ ] 打开一份 09-06 之后、本修复之前生成的报告:标题还在,不报错,不出现「图盘数据无效」(因为根本没有围栏块)。图可以没有。 + +未打勾的项写成「未验证」。发现南印度方格、空白标题、导出文件里大段 JSON,或暗色主题下图看不见,把现象和报告页截图贴回。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 538490d5..3b8ef99f 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -357,6 +357,7 @@ Text release is paced, not animated: the frame buffer commits at most once per a - **Structure:** sticky screen chrome (back, secondary print, primary “导出报告(.md)”), then the longform Markdown body with a sticky heading TOC. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”. - **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette. - **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed. +- **Charts:** each `####` chart heading is followed by a North Indian diamond figure, not engine SVG HTML. Figures are max 360px and centered. From 720px, consecutive heading+figure pairs sit two to a row; print and narrower viewports stay one column. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. - **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge. - **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them. - **Width:** 1120px for the reader chrome so the TOC rail can sit beside the article; the TOC stacks into a drawer below 860px. diff --git a/frontend/package.json b/frontend/package.json index 980a68e4..b75db36b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "test": "tsx --test tests/*.test.ts", + "test": "tsx --test tests/*.test.ts tests/*.test.tsx", "test:db": "tsx --test --test-concurrency=1 tests/database-*.test.ts", "test:deployment": "tsx --test tests/health-deployment.test.ts tests/staging-backend-workflows.test.ts tests/staging-image-manifest.test.ts", "db:migrate": "node scripts/db-migrate.mjs", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 8764c000..7b982d71 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3815,6 +3815,37 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .personal-report-md-lead, .personal-report-md-section { min-width: 0; } +.personal-report-md-article h4.personal-report-chart-heading { + margin: var(--space-5) 0 var(--space-2); + font-size: var(--type-title-sm); + font-weight: 500; +} +@media (min-width: 720px) { + /* Charts live in the eager 摘要 lead, not only later lazy H2 sections. */ + .personal-report-md-lead > h4.personal-report-chart-heading:has(+ .personal-report-chart-figure), + .personal-report-md-section > h4.personal-report-chart-heading:has(+ .personal-report-chart-figure) { + float: left; + width: 50%; + box-sizing: border-box; + padding-inline: var(--space-3); + margin-top: var(--space-5); + margin-bottom: 0; + } + .personal-report-md-lead > h4.personal-report-chart-heading + .personal-report-chart-figure, + .personal-report-md-section > h4.personal-report-chart-heading + .personal-report-chart-figure { + float: left; + width: 50%; + margin-left: -50%; + margin-top: var(--space-5); + padding-top: 2.4em; + box-sizing: border-box; + max-width: none; + } + .personal-report-md-lead > .personal-report-chart-figure + :not(h4):not(.personal-report-chart-figure), + .personal-report-md-section > .personal-report-chart-figure + :not(h4):not(.personal-report-chart-figure) { + clear: both; + } +} @media (max-width: 860px) { .personal-report-md-layout { grid-template-columns: 1fr; } .personal-report-toc { position: static; max-height: none; } @@ -4092,12 +4123,22 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .personal-report-subheading span { color: var(--color-ink-secondary); font-size: var(--type-caption); } .personal-report-chart-svg { display: block; width: 100%; height: auto; margin-top: var(--space-4); color: var(--color-ink); } -.personal-report-chart-svg .personal-report-chart-cell { fill: none; stroke: var(--color-ink-strong); stroke-width: 1.25; } -.personal-report-chart-svg .personal-report-chart-core { fill: var(--color-canvas-muted); stroke: var(--color-ink-strong); stroke-width: 1.25; } -.personal-report-chart-svg .personal-report-chart-diamond { fill: none; stroke: var(--color-ink-strong); stroke-width: 1.25; } +.personal-report-chart-svg .personal-report-chart-cell { fill: none; stroke: var(--color-border-strong); stroke-width: 1.25; } +.personal-report-chart-svg .personal-report-chart-core { fill: var(--color-canvas-muted); stroke: var(--color-border-strong); stroke-width: 1.25; } +.personal-report-chart-svg .personal-report-chart-diamond { fill: none; stroke: var(--color-border-strong); stroke-width: 1.25; } .personal-report-chart-svg text { fill: currentColor; } -.personal-report-chart-svg .personal-report-chart-muted { fill: var(--color-ink-tertiary); } -.personal-report-chart-figure { min-width: 0; margin: 0; } +.personal-report-chart-svg .personal-report-chart-muted { fill: var(--color-ink-muted, var(--color-ink-tertiary)); } +.personal-report-chart-figure { + min-width: 0; + max-width: 360px; + width: 100%; + margin: var(--space-4) auto var(--space-6); +} +.personal-report-chart-invalid { + margin: var(--space-3) 0; + color: var(--color-ink-muted, var(--color-ink-tertiary)); + font-size: var(--type-caption); +} .personal-report-chart-figure figcaption { margin-top: var(--space-3); color: var(--color-ink-tertiary); @@ -4365,8 +4406,25 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .personal-report-narrative p { font-size: 9.6pt; line-height: 1.72; orphans: 3; widows: 3; } .personal-report-lead-grid { grid-template-columns: minmax(0, 1.04fr) minmax(64mm, .96fr); gap: 8mm; } .personal-report-chart-svg { max-width: none; margin-top: 3mm; } + .personal-report-md-article .personal-report-chart-figure { + float: none; + width: auto; + max-width: 360px; + margin-left: auto; + margin-right: auto; + padding-top: 0; + break-inside: avoid; + } + .personal-report-md-article h4.personal-report-chart-heading { + float: none; + width: auto; + margin-left: auto; + margin-right: auto; + max-width: 360px; + padding-inline: 0; + } .personal-report-chart-grid, - .personal-report-chart-grid.is-single { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .personal-report-chart-grid.is-single { grid-template-columns: 1fr; } .personal-report-chart-grid.is-single { max-width: 118mm; } .personal-report-table-wrap { max-width: none !important; overflow: visible !important; } .personal-report-table-wrap table, diff --git a/frontend/src/components/markdown-code-block.tsx b/frontend/src/components/markdown-code-block.tsx index 3223265d..2002f417 100644 --- a/frontend/src/components/markdown-code-block.tsx +++ b/frontend/src/components/markdown-code-block.tsx @@ -4,13 +4,13 @@ import { Check, Copy } from "lucide-react"; import { isValidElement, useEffect, useRef, useState, type ReactNode } from "react"; /** The fenced language, from the `language-xxx` class react-markdown puts on . */ -function fencedLanguage(node: ReactNode): string { +export function fencedLanguage(node: ReactNode): string { if (!isValidElement<{ className?: string }>(node)) return ""; return /language-([\w+-]+)/.exec(node.props.className ?? "")?.[1] ?? ""; } /** The raw source inside the fence, for the copy button. */ -function fencedSource(node: ReactNode): string { +export function fencedSource(node: ReactNode): string { if (typeof node === "string") return node; if (Array.isArray(node)) return node.map(fencedSource).join(""); if (isValidElement<{ children?: ReactNode }>(node)) return fencedSource(node.props.children); diff --git a/frontend/src/components/personal-report/personal-report-markdown-view.tsx b/frontend/src/components/personal-report/personal-report-markdown-view.tsx index b1a620bf..b35da9f5 100644 --- a/frontend/src/components/personal-report/personal-report-markdown-view.tsx +++ b/frontend/src/components/personal-report/personal-report-markdown-view.tsx @@ -4,6 +4,13 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; +import { fencedLanguage, fencedSource } from "@/components/markdown-code-block"; +import { + chartAriaLabel, + parseReportChartBlock, + toNorthIndianChart, +} from "@/lib/report-chart-block"; +import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg"; import { buildLongformOutline, type LongformHeading, @@ -49,6 +56,32 @@ function markdownComponents(headings: readonly LongformHeading[]): Components { ), h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, + h4: ({ children }) => { + const title = flattenText(children).trim(); + const isChart = /^(?:D\d{1,3}\b|Moon Chart)/.test(title); + return ( +

+ {children} +

+ ); + }, + pre: ({ children, ...props }) => { + if (fencedLanguage(children) !== "jyotish-chart") { + return
{children}
; + } + const block = parseReportChartBlock(fencedSource(children).trim()); + if (!block) { + return

图盘数据无效

; + } + return ( +
+ +
+ ); + }, }; } diff --git a/frontend/src/components/personal-report/vedic-chart-svg.tsx b/frontend/src/components/personal-report/vedic-chart-svg.tsx index 3688f852..2ef692ba 100644 --- a/frontend/src/components/personal-report/vedic-chart-svg.tsx +++ b/frontend/src/components/personal-report/vedic-chart-svg.tsx @@ -1,101 +1,83 @@ +"use client"; + /** - * Pure React SVG North Indian (Rasi) chart for the personal report. + * Pure React SVG North Indian (Rasi) chart. * - * - All text comes from the schema-validated report document (house sign, - * occupants, retrograde markers from the real planets array). - * - No canvas, no base64, no remote images/fonts, no inner-HTML injection APIs. - * - Uses a viewBox so print output stays sharp at any size. - * - Fixed house cells follow the standard North Indian layout: - * - * 12 | 11 | 10 | 9 - * 1 | | | 8 - * 2 | | | 7 - * 3 | 4 | 5 | 6 + * House geometry is the standard diamond chart: two diagonals plus the + * inner rhombus from the four midpoints. Sign numbers are rasi ordinals + * (1 = Aries … 12 = Pisces). Occupant labels are already display text. */ import { useId } from "react"; import type { ChartV1 } from "@/lib/personal-report-contract"; +import { + CHART_SIZE, + HOUSE_NUMBERS, + HOUSE_POLYGONS, + polygonCentroid, + polygonPoints, + signNumberAnchor, +} from "@/lib/north-indian-chart-geometry"; +import { signOrdinal, type NorthIndianChartModel } from "@/lib/report-chart-block"; -type HouseV1 = ChartV1["houses"][number]; +export const CHART_VIEWBOX_WIDTH = CHART_SIZE; +export const CHART_VIEWBOX_HEIGHT = CHART_SIZE; -export const CHART_VIEWBOX_WIDTH = 400; -export const CHART_VIEWBOX_HEIGHT = 400; -const CELL = 100; - -const HOUSE_CELLS: Record = { - 1: { x: 0, y: CELL }, - 2: { x: 0, y: CELL * 2 }, - 3: { x: 0, y: CELL * 3 }, - 4: { x: CELL, y: CELL * 3 }, - 5: { x: CELL * 2, y: CELL * 3 }, - 6: { x: CELL * 3, y: CELL * 3 }, - 7: { x: CELL * 3, y: CELL * 2 }, - 8: { x: CELL * 3, y: CELL }, - 9: { x: CELL * 3, y: 0 }, - 10: { x: CELL * 2, y: 0 }, - 11: { x: CELL, y: 0 }, - 12: { x: 0, y: 0 }, -}; - -const HOUSE_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; +export type VedicChartModel = NorthIndianChartModel; interface VedicChartSvgProps { - chart: ChartV1; + chart: NorthIndianChartModel; ariaLabel: string; } -function buildRetrogradeSet(chart: ChartV1): Set { +function buildRetrogradeSet(chart: NorthIndianChartModel): Set { const retrograde = new Set(); for (const planet of chart.planets ?? []) { - if (planet.retrograde) { - retrograde.add(planet.name); - } + if (planet.retrograde) retrograde.add(planet.name); } return retrograde; } -/** Combine house occupants with real retrograde markers from chart.planets. */ -function occupantLines(house: HouseV1, chart: ChartV1): string[] { +function occupantLines(house: NorthIndianChartModel["houses"][number], chart: NorthIndianChartModel): string[] { const retrograde = buildRetrogradeSet(chart); - return house.occupants.map((name) => (retrograde.has(name) ? `${name}逆` : name)); + return house.occupants.map((name) => { + if (name.includes("逆") || name.includes("°")) return name; + return retrograde.has(name) ? `${name}逆` : name; + }); } -const MAX_RENDERED_OCCUPANTS = 8; - -function truncateSvgLabel(value: string, maxCharacters: number): string { - const characters = Array.from(value); - if (characters.length <= maxCharacters) return value; - return `${characters.slice(0, Math.max(1, maxCharacters - 1)).join("")}…`; -} +const MAX_RENDERED_OCCUPANTS = 6; function visibleOccupantLines(lines: string[]): string[] { if (lines.length <= MAX_RENDERED_OCCUPANTS) return lines; - return [...lines.slice(0, MAX_RENDERED_OCCUPANTS - 1), `+${lines.length - (MAX_RENDERED_OCCUPANTS - 1)} 项`]; + return [...lines.slice(0, MAX_RENDERED_OCCUPANTS - 1), `+${lines.length - (MAX_RENDERED_OCCUPANTS - 1)}`]; } -function PlanetList({ lines }: { lines: string[] }) { +function OccupantStack({ + lines, + origin, +}: { + lines: string[]; + origin: { x: number; y: number }; +}) { const visible = visibleOccupantLines(lines); - const twoColumns = visible.length > 4; - const fontSize = twoColumns ? 8.5 : visible.length > 2 ? 10 : 11; - const rowStep = twoColumns ? 13 : 15; - const maxCharacters = twoColumns ? 5 : 8; + const fontSize = visible.length > 4 ? 8 : 9; + const rowStep = visible.length > 4 ? 10 : 12; + const startY = origin.y - ((visible.length - 1) * rowStep) / 2; return ( <> - {visible.map((line, index) => { - const column = twoColumns ? Math.floor(index / 4) : 0; - const row = twoColumns ? index % 4 : index; - return ( + {visible.map((line, index) => ( - {truncateSvgLabel(line, maxCharacters)} + {line} - ); - })} + ))} ); } @@ -106,9 +88,7 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) { for (const house of chart.houses) { linesByHouse.set(house.houseNumber, occupantLines(house, chart)); } - const hasRetrogradeMarker = chart.houses.some((house) => - house.occupants.some((name) => (chart.planets ?? []).some((p) => p.retrograde && p.name === name)), - ); + const hasRetrogradeMarker = [...linesByHouse.values()].some((lines) => lines.some((line) => line.includes("逆"))); return ( - {HOUSE_NUMBERS.map((houseNumber) => { - const cell = HOUSE_CELLS[houseNumber]; - return ( - - - - ); - })} + {HOUSE_NUMBERS.map((houseNumber) => ( + + + + ))} {HOUSE_NUMBERS.map((houseNumber) => { - const cell = HOUSE_CELLS[houseNumber]; - const house = chart.houses.find((h) => h.houseNumber === houseNumber); + const polygon = HOUSE_POLYGONS[houseNumber]; + const house = chart.houses.find((item) => item.houseNumber === houseNumber); const lines = house ? linesByHouse.get(houseNumber) ?? [] : []; + const centroid = polygonCentroid(polygon); + const numberAnchor = signNumberAnchor(houseNumber); + const ordinal = house ? signOrdinal(house.sign) : null; return ( - - - {houseNumber} - - {house && ( - - {house.sign} + {ordinal !== null && ( + + {ordinal} )} - - + + ); })} - - {hasRetrogradeMarker && ( - + “逆”=逆行 )} @@ -176,7 +152,9 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) { ); } -export function hasRealChartData(chart: ChartV1): boolean { +export const NorthIndianChartSvg = VedicChartSvg; + +export function hasRealChartData(chart: NorthIndianChartModel): boolean { return Array.isArray(chart.houses) && chart.houses.length > 0 && chart.houses.some((house) => Array.isArray(house.occupants) && house.occupants.length > 0); } diff --git a/frontend/src/lib/north-indian-chart-geometry.ts b/frontend/src/lib/north-indian-chart-geometry.ts new file mode 100644 index 00000000..82731394 --- /dev/null +++ b/frontend/src/lib/north-indian-chart-geometry.ts @@ -0,0 +1,114 @@ +/** North Indian (diamond) house polygons for a square of side S. */ + +export const CHART_SIZE = 400; + +export type Point = Readonly<{ x: number; y: number }>; + +const S = CHART_SIZE; +const H = S / 2; +const Q = S / 4; + +/** House 1 is the top diamond; numbers run clockwise from there. */ +export const HOUSE_POLYGONS: Readonly> = { + 1: [{ x: H, y: 0 }, { x: 3 * Q, y: Q }, { x: H, y: H }, { x: Q, y: Q }], + 2: [{ x: 0, y: 0 }, { x: H, y: 0 }, { x: Q, y: Q }], + 3: [{ x: 0, y: 0 }, { x: Q, y: Q }, { x: 0, y: H }], + 4: [{ x: 0, y: H }, { x: Q, y: Q }, { x: H, y: H }, { x: Q, y: 3 * Q }], + 5: [{ x: 0, y: H }, { x: Q, y: 3 * Q }, { x: 0, y: S }], + 6: [{ x: 0, y: S }, { x: Q, y: 3 * Q }, { x: H, y: S }], + 7: [{ x: H, y: S }, { x: Q, y: 3 * Q }, { x: H, y: H }, { x: 3 * Q, y: 3 * Q }], + 8: [{ x: H, y: S }, { x: 3 * Q, y: 3 * Q }, { x: S, y: S }], + 9: [{ x: S, y: S }, { x: 3 * Q, y: 3 * Q }, { x: S, y: H }], + 10: [{ x: S, y: H }, { x: 3 * Q, y: 3 * Q }, { x: H, y: H }, { x: 3 * Q, y: Q }], + 11: [{ x: S, y: H }, { x: 3 * Q, y: Q }, { x: S, y: 0 }], + 12: [{ x: S, y: 0 }, { x: 3 * Q, y: Q }, { x: H, y: 0 }], +}; + +export const HOUSE_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const; + +const CENTER: Point = { x: H, y: H }; + +export function polygonPoints(points: readonly Point[]): string { + return points.map((point) => `${point.x},${point.y}`).join(" "); +} + +export function polygonCentroid(points: readonly Point[]): Point { + const areaTimes2 = points.reduce((sum, point, index) => { + const next = points[(index + 1) % points.length]; + return sum + (point.x * next.y - next.x * point.y); + }, 0); + if (Math.abs(areaTimes2) < 1e-9) { + const n = points.length || 1; + return { + x: points.reduce((sum, point) => sum + point.x, 0) / n, + y: points.reduce((sum, point) => sum + point.y, 0) / n, + }; + } + const cx = points.reduce((sum, point, index) => { + const next = points[(index + 1) % points.length]; + return sum + (point.x + next.x) * (point.x * next.y - next.x * point.y); + }, 0) / (3 * areaTimes2); + const cy = points.reduce((sum, point, index) => { + const next = points[(index + 1) % points.length]; + return sum + (point.y + next.y) * (point.x * next.y - next.x * point.y); + }, 0) / (3 * areaTimes2); + return { x: cx, y: cy }; +} + +export function polygonArea(points: readonly Point[]): number { + const sum = points.reduce((total, point, index) => { + const next = points[(index + 1) % points.length]; + return total + (point.x * next.y - next.x * point.y); + }, 0); + return Math.abs(sum) / 2; +} + +export function pointInPolygon(point: Point, polygon: readonly Point[]): boolean { + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { + const a = polygon[i]; + const b = polygon[j]; + const intersect = (a.y > point.y) !== (b.y > point.y) + && point.x < ((b.x - a.x) * (point.y - a.y)) / ((b.y - a.y) || Number.EPSILON) + a.x; + if (intersect) inside = !inside; + } + return inside; +} + +function distance(a: Point, b: Point): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +function moveToward(from: Point, to: Point, distancePx: number): Point { + const dx = to.x - from.x; + const dy = to.y - from.y; + const length = Math.hypot(dx, dy) || 1; + return { x: from.x + (dx / length) * distancePx, y: from.y + (dy / length) * distancePx }; +} + +function rightAngleVertex(points: readonly Point[]): Point { + for (let index = 0; index < points.length; index += 1) { + const prev = points[(index + points.length - 1) % points.length]; + const vertex = points[index]; + const next = points[(index + 1) % points.length]; + const d1x = prev.x - vertex.x; + const d1y = prev.y - vertex.y; + const d2x = next.x - vertex.x; + const d2y = next.y - vertex.y; + if (Math.abs(d1x * d2x + d1y * d2y) < 1e-6) return vertex; + } + return points[0]; +} + +function innerVertex(points: readonly Point[]): Point { + return points.reduce((best, point) => (distance(point, CENTER) < distance(best, CENTER) ? point : best)); +} + +export function signNumberAnchor(houseNumber: number): Point { + const polygon = HOUSE_POLYGONS[houseNumber]; + const centroid = polygonCentroid(polygon); + if (polygon.length === 4) { + return moveToward(innerVertex(polygon), centroid, 12); + } + return moveToward(rightAngleVertex(polygon), centroid, 14); +} diff --git a/frontend/src/lib/personal-report-longform-download.ts b/frontend/src/lib/personal-report-longform-download.ts index 097e5563..a757eae2 100644 --- a/frontend/src/lib/personal-report-longform-download.ts +++ b/frontend/src/lib/personal-report-longform-download.ts @@ -1,6 +1,7 @@ import { downloadMarkdownReport } from "./consultation-report-export"; import { PERSONAL_REPORT_LEGACY_PLACEHOLDER } from "./personal-report-longform-copy"; import { personalReportMarkdownFilename } from "./personal-report-longform-outline"; +import { stripReportChartBlocks } from "./report-chart-block"; export async function requestPersonalReportLongformAppendix(reportId: string): Promise { const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/professional-reference`, { @@ -30,5 +31,8 @@ export async function downloadPersonalReportLongformAppendix( const markdown = options.markdown?.trim() ? options.markdown : await requestPersonalReportLongformAppendix(reportId); - downloadMarkdownReport(personalReportMarkdownFilename(options.reportDate), markdown); + downloadMarkdownReport( + personalReportMarkdownFilename(options.reportDate), + stripReportChartBlocks(markdown), + ); } diff --git a/frontend/src/lib/report-chart-block.ts b/frontend/src/lib/report-chart-block.ts new file mode 100644 index 00000000..ea5bdaa8 --- /dev/null +++ b/frontend/src/lib/report-chart-block.ts @@ -0,0 +1,166 @@ +import { z } from "zod"; + +const SIGNS = [ + "Aries", + "Taurus", + "Gemini", + "Cancer", + "Leo", + "Virgo", + "Libra", + "Scorpio", + "Sagittarius", + "Capricorn", + "Aquarius", + "Pisces", +] as const; + +const PLANETS = [ + "Sun", + "Moon", + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn", + "Rahu", + "Ketu", +] as const; + +const PLANET_LABEL: Record<(typeof PLANETS)[number], string> = { + Sun: "日", + Moon: "月", + Mars: "火", + Mercury: "水", + Jupiter: "木", + Venus: "金", + Saturn: "土", + Rahu: "罗", + Ketu: "计", +}; + +const SIGN_NUMBER: Record = { + Aries: 1, + Taurus: 2, + Gemini: 3, + Cancer: 4, + Leo: 5, + Virgo: 6, + Libra: 7, + Scorpio: 8, + Sagittarius: 9, + Capricorn: 10, + Aquarius: 11, + Pisces: 12, + 白羊: 1, + 金牛: 2, + 双子: 3, + 巨蟹: 4, + 狮子: 5, + 处女: 6, + 天秤: 7, + 天蝎: 8, + 射手: 9, + 摩羯: 10, + 水瓶: 11, + 双鱼: 12, + 白羊座: 1, + 金牛座: 2, + 双子座: 3, + 巨蟹座: 4, + 狮子座: 5, + 处女座: 6, + 天秤座: 7, + 天蝎座: 8, + 射手座: 9, + 摩羯座: 10, + 水瓶座: 11, + 双鱼座: 12, +}; + +const FENCE_RE = /(?:\n[ \t]*)?```jyotish-chart\n[\s\S]*?```(?:\n[ \t]*)?/g; + +export const reportChartBlockSchema = z.strictObject({ + version: z.literal(1), + id: z.string().regex(/^(D\d{1,3}|MOON)$/), + title: z.string().max(120), + layout: z.literal("north"), + ascendant: z.strictObject({ + sign: z.enum(SIGNS), + degree: z.number().gte(0).lt(30), + }), + planets: z.array(z.strictObject({ + name: z.enum(PLANETS), + sign: z.enum(SIGNS), + degree: z.number().gte(0).lt(30), + retrograde: z.boolean(), + })).max(9), +}); + +export type ReportChartBlock = z.infer; + +export type NorthIndianChartModel = { + houses: { houseNumber: number; sign: string; occupants: string[] }[]; + planets?: { name: string; retrograde: boolean }[]; +}; + +export function parseReportChartBlock(source: string): ReportChartBlock | null { + try { + return reportChartBlockSchema.parse(JSON.parse(source)); + } catch { + return null; + } +} + +function integerDegree(degree: number): number { + const value = Math.floor(degree); + if (value >= 30) return 29; + if (value < 0) return 0; + return value; +} + +export function planetDisplayLabel( + name: (typeof PLANETS)[number], + degree: number, + retrograde: boolean, +): string { + const body = PLANET_LABEL[name]; + const suffix = retrograde ? "逆" : ""; + return `${body}${suffix} ${integerDegree(degree)}°`; +} + +export function signOrdinal(sign: string): number | null { + return SIGN_NUMBER[sign] ?? null; +} + +export function chartAriaLabel(id: ReportChartBlock["id"]): string { + return id === "MOON" ? "月亮参考盘" : `${id} 北印度盘`; +} + +export function toNorthIndianChart(block: ReportChartBlock): NorthIndianChartModel { + const ascIndex = SIGNS.indexOf(block.ascendant.sign); + const occupantsByHouse: string[][] = Array.from({ length: 12 }, () => []); + for (const planet of block.planets) { + const signIndex = SIGNS.indexOf(planet.sign); + const houseNumber = ((signIndex - ascIndex + 12) % 12) + 1; + occupantsByHouse[houseNumber - 1].push( + planetDisplayLabel(planet.name, planet.degree, planet.retrograde), + ); + } + return { + houses: Array.from({ length: 12 }, (_, index) => ({ + houseNumber: index + 1, + sign: SIGNS[(ascIndex + index + 12) % 12], + occupants: occupantsByHouse[index], + })), + planets: block.planets.map((planet) => ({ + name: PLANET_LABEL[planet.name], + retrograde: planet.retrograde, + })), + }; +} + +export function stripReportChartBlocks(markdown: string): string { + const stripped = markdown.replace(FENCE_RE, "\n\n"); + return stripped.replace(/\n{3,}/g, "\n\n"); +} diff --git a/frontend/tests/fixtures/report-chart-blocks-golden.json b/frontend/tests/fixtures/report-chart-blocks-golden.json new file mode 100644 index 00000000..1872e7f6 --- /dev/null +++ b/frontend/tests/fixtures/report-chart-blocks-golden.json @@ -0,0 +1,1457 @@ +{ + "sourceCommand": ".venv/bin/python -m pytest tests/test_report_chart_block.py -q # build_professional_report_reference_packet → render_pl9_markdown, fictional 1990-06-15 12:00 Beijing", + "blocks": [ + { + "version": 1, + "id": "D1", + "title": "D1 — Rashi Chart(本命盘)", + "layout": "north", + "ascendant": { + "sign": "Leo", + "degree": 29.4 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 1.53, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Aquarius", + "degree": 18.65, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Pisces", + "degree": 18.52, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Taurus", + "degree": 12.84, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Gemini", + "degree": 23.54, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Aries", + "degree": 26.11, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Capricorn", + "degree": 1.77, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Capricorn", + "degree": 17.44, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Cancer", + "degree": 17.44, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D9", + "title": "D9 — Navamsa(婚盘)", + "layout": "north", + "ascendant": { + "sign": "Sagittarius", + "degree": 24.59 + }, + "planets": [ + { + "name": "Sun", + "sign": "Libra", + "degree": 13.8, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Pisces", + "degree": 17.82, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Sagittarius", + "degree": 16.72, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Aries", + "degree": 25.59, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Taurus", + "degree": 1.86, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Scorpio", + "degree": 24.97, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Capricorn", + "degree": 15.97, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Gemini", + "degree": 6.94, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Sagittarius", + "degree": 6.94, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "MOON", + "title": "Moon Chart(月亮参考盘)", + "layout": "north", + "ascendant": { + "sign": "Aquarius", + "degree": 18.65 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 1.53, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Aquarius", + "degree": 18.65, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Pisces", + "degree": 18.52, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Taurus", + "degree": 12.84, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Gemini", + "degree": 23.54, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Aries", + "degree": 26.11, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Capricorn", + "degree": 1.77, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Capricorn", + "degree": 17.44, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Cancer", + "degree": 17.44, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D2", + "title": "D2 — Hora(财富分盘)", + "layout": "north", + "ascendant": { + "sign": "Cancer", + "degree": 28.8 + }, + "planets": [ + { + "name": "Sun", + "sign": "Leo", + "degree": 3.07, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Cancer", + "degree": 7.29, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Leo", + "degree": 7.05, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Cancer", + "degree": 25.69, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Cancer", + "degree": 17.08, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Cancer", + "degree": 22.22, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Cancer", + "degree": 3.55, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Leo", + "degree": 4.88, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Leo", + "degree": 4.88, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D3", + "title": "D3 — Drekkana(手足与行动)", + "layout": "north", + "ascendant": { + "sign": "Aries", + "degree": 28.2 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 4.6, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Gemini", + "degree": 25.94, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Cancer", + "degree": 25.57, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Virgo", + "degree": 8.53, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Aquarius", + "degree": 10.62, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Sagittarius", + "degree": 18.32, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Capricorn", + "degree": 5.32, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Taurus", + "degree": 22.31, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Scorpio", + "degree": 22.31, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D4", + "title": "D4 — Turyamsa(居所与根基)", + "layout": "north", + "ascendant": { + "sign": "Taurus", + "degree": 27.6 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 6.14, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Leo", + "degree": 14.58, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Virgo", + "degree": 14.1, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Leo", + "degree": 21.38, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Pisces", + "degree": 4.16, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Capricorn", + "degree": 14.43, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Capricorn", + "degree": 7.1, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Cancer", + "degree": 9.75, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Capricorn", + "degree": 9.75, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D5", + "title": "D5 — Panchamsa(声望与表达)", + "layout": "north", + "ascendant": { + "sign": "Sagittarius", + "degree": 27.0 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 7.67, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Taurus", + "degree": 3.23, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Aquarius", + "degree": 2.62, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Pisces", + "degree": 4.22, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Virgo", + "degree": 27.7, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Leo", + "degree": 10.54, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Virgo", + "degree": 8.87, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Scorpio", + "degree": 27.19, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Taurus", + "degree": 27.19, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D6", + "title": "D6 — Shashthamsa(压力与恢复)", + "layout": "north", + "ascendant": { + "sign": "Capricorn", + "degree": 26.4 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 9.2, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Taurus", + "degree": 21.88, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Sagittarius", + "degree": 21.15, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Capricorn", + "degree": 17.06, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Libra", + "degree": 21.24, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Virgo", + "degree": 6.65, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Cancer", + "degree": 10.64, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Libra", + "degree": 14.63, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Aries", + "degree": 14.63, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D7", + "title": "D7 — Saptamsa(创造与子女)", + "layout": "north", + "ascendant": { + "sign": "Aquarius", + "degree": 25.8 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 10.74, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Gemini", + "degree": 10.52, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Capricorn", + "degree": 9.67, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Capricorn", + "degree": 29.91, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Scorpio", + "degree": 14.78, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Libra", + "degree": 2.76, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Cancer", + "degree": 12.42, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Scorpio", + "degree": 2.07, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Taurus", + "degree": 2.07, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D8", + "title": "D8 — Ashtamsa(突发变化与韧性)", + "layout": "north", + "ascendant": { + "sign": "Pisces", + "degree": 25.2 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 12.27, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Gemini", + "degree": 29.17, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Pisces", + "degree": 28.19, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Aries", + "degree": 12.75, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Sagittarius", + "degree": 8.32, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Libra", + "degree": 28.87, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Virgo", + "degree": 14.19, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Capricorn", + "degree": 19.5, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Cancer", + "degree": 19.5, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D9", + "title": "D9 — Navamsa(关系、承诺与法则分盘)", + "layout": "north", + "ascendant": { + "sign": "Sagittarius", + "degree": 24.59 + }, + "planets": [ + { + "name": "Sun", + "sign": "Libra", + "degree": 13.8, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Pisces", + "degree": 17.82, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Sagittarius", + "degree": 16.72, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Aries", + "degree": 25.59, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Taurus", + "degree": 1.86, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Scorpio", + "degree": 24.97, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Capricorn", + "degree": 15.97, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Gemini", + "degree": 6.94, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Sagittarius", + "degree": 6.94, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D10", + "title": "D10 — Dasamsa(事业分盘)", + "layout": "north", + "ascendant": { + "sign": "Taurus", + "degree": 23.99 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 15.34, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Leo", + "degree": 6.46, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Taurus", + "degree": 5.24, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Taurus", + "degree": 8.44, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Capricorn", + "degree": 25.4, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Sagittarius", + "degree": 21.08, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Virgo", + "degree": 17.74, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Aquarius", + "degree": 24.38, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Leo", + "degree": 24.38, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D11", + "title": "D11 — Rudramsa(收益、社群与愿望分盘)", + "layout": "north", + "ascendant": { + "sign": "Gemini", + "degree": 23.39 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 16.87, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Leo", + "degree": 25.11, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Taurus", + "degree": 23.77, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Taurus", + "degree": 21.28, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Aquarius", + "degree": 18.94, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Capricorn", + "degree": 17.19, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Virgo", + "degree": 19.51, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Pisces", + "degree": 11.82, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Virgo", + "degree": 11.82, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D12", + "title": "D12 — Dwadashamsa(父母家族)", + "layout": "north", + "ascendant": { + "sign": "Cancer", + "degree": 22.79 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 18.41, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Virgo", + "degree": 13.75, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Libra", + "degree": 12.29, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Libra", + "degree": 4.13, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Pisces", + "degree": 12.48, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Aquarius", + "degree": 13.3, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Capricorn", + "degree": 21.29, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Cancer", + "degree": 29.26, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Capricorn", + "degree": 29.26, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D16", + "title": "D16 — Shodasamsa(享受与载具)", + "layout": "north", + "ascendant": { + "sign": "Cancer", + "degree": 20.39 + }, + "planets": [ + { + "name": "Sun", + "sign": "Aries", + "degree": 24.54, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Capricorn", + "degree": 28.34, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Taurus", + "degree": 26.39, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Aquarius", + "degree": 25.5, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Aries", + "degree": 16.64, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Taurus", + "degree": 27.73, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Leo", + "degree": 28.38, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Taurus", + "degree": 9.01, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Taurus", + "degree": 9.01, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D20", + "title": "D20 — Vimsamsa(灵修与信念)", + "layout": "north", + "ascendant": { + "sign": "Scorpio", + "degree": 17.99 + }, + "planets": [ + { + "name": "Sun", + "sign": "Taurus", + "degree": 0.68, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Aries", + "degree": 12.92, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Sagittarius", + "degree": 10.48, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Leo", + "degree": 16.88, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Cancer", + "degree": 20.8, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Virgo", + "degree": 12.16, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Capricorn", + "degree": 5.48, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Scorpio", + "degree": 18.76, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Scorpio", + "degree": 18.76, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D24", + "title": "D24 — Siddhamsa(学习与技能)", + "layout": "north", + "ascendant": { + "sign": "Cancer", + "degree": 15.59 + }, + "planets": [ + { + "name": "Sun", + "sign": "Virgo", + "degree": 6.81, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Libra", + "degree": 27.51, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Virgo", + "degree": 24.58, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Taurus", + "degree": 8.25, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Aquarius", + "degree": 24.96, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Aries", + "degree": 26.6, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Leo", + "degree": 12.58, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Leo", + "degree": 28.51, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Leo", + "degree": 28.51, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D27", + "title": "D27 — Bhamsa(内在力量)", + "layout": "north", + "ascendant": { + "sign": "Gemini", + "degree": 13.78 + }, + "planets": [ + { + "name": "Sun", + "sign": "Taurus", + "degree": 11.41, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Leo", + "degree": 23.45, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Aquarius", + "degree": 20.15, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Virgo", + "degree": 16.78, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Capricorn", + "degree": 5.58, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Pisces", + "degree": 14.92, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Scorpio", + "degree": 17.9, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Capricorn", + "degree": 20.83, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Capricorn", + "degree": 20.83, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D30", + "title": "D30 — Trimsamsa(困难与脆弱点)", + "layout": "north", + "ascendant": { + "sign": "Libra", + "degree": 11.98 + }, + "planets": [ + { + "name": "Sun", + "sign": "Aries", + "degree": 16.01, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Gemini", + "degree": 19.39, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Capricorn", + "degree": 15.73, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Capricorn", + "degree": 25.32, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Gemini", + "degree": 16.2, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Libra", + "degree": 3.25, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Taurus", + "degree": 23.22, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Capricorn", + "degree": 13.14, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Capricorn", + "degree": 13.14, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D40", + "title": "D40 — Khavedamsa(母系福分)", + "layout": "north", + "ascendant": { + "sign": "Cancer", + "degree": 5.98 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 1.35, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Aries", + "degree": 25.85, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Libra", + "degree": 20.97, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Pisces", + "degree": 3.75, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Scorpio", + "degree": 11.59, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Aquarius", + "degree": 24.33, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Sagittarius", + "degree": 10.96, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Virgo", + "degree": 7.52, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Virgo", + "degree": 7.52, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D45", + "title": "D45 — Akshavedamsa(父系福分)", + "layout": "north", + "ascendant": { + "sign": "Sagittarius", + "degree": 2.97 + }, + "planets": [ + { + "name": "Sun", + "sign": "Gemini", + "degree": 9.02, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Cancer", + "degree": 29.08, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Capricorn", + "degree": 23.59, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Taurus", + "degree": 7.97, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Pisces", + "degree": 9.29, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Cancer", + "degree": 4.87, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Sagittarius", + "degree": 19.83, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Sagittarius", + "degree": 4.72, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Sagittarius", + "degree": 4.72, + "retrograde": true + } + ] + }, + { + "version": 1, + "id": "D60", + "title": "D60 — Shashtyamsa(深层业力)", + "layout": "north", + "ascendant": { + "sign": "Gemini", + "degree": 23.96 + }, + "planets": [ + { + "name": "Sun", + "sign": "Virgo", + "degree": 2.03, + "retrograde": false + }, + { + "name": "Moon", + "sign": "Pisces", + "degree": 8.77, + "retrograde": false + }, + { + "name": "Mars", + "sign": "Taurus", + "degree": 1.45, + "retrograde": false + }, + { + "name": "Mercury", + "sign": "Cancer", + "degree": 20.63, + "retrograde": false + }, + { + "name": "Jupiter", + "sign": "Taurus", + "degree": 2.39, + "retrograde": false + }, + { + "name": "Venus", + "sign": "Leo", + "degree": 6.49, + "retrograde": false + }, + { + "name": "Saturn", + "sign": "Taurus", + "degree": 16.44, + "retrograde": true + }, + { + "name": "Rahu", + "sign": "Sagittarius", + "degree": 26.29, + "retrograde": true + }, + { + "name": "Ketu", + "sign": "Gemini", + "degree": 26.29, + "retrograde": true + } + ] + } + ] +} diff --git a/frontend/tests/personal-report-markdown-view.test.tsx b/frontend/tests/personal-report-markdown-view.test.tsx new file mode 100644 index 00000000..464279fa --- /dev/null +++ b/frontend/tests/personal-report-markdown-view.test.tsx @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import test from "node:test"; + +import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view.tsx"; +import { planetDisplayLabel, type ReportChartBlock } from "../src/lib/report-chart-block.ts"; + +Object.assign(globalThis, { React }); + +const SAMPLE_BLOCK: ReportChartBlock = { + version: 1, + id: "D1", + title: "D1 — Rashi Chart(本命盘)", + layout: "north", + ascendant: { sign: "Leo", degree: 12.34 }, + planets: [ + { name: "Sun", sign: "Leo", degree: 3.21, retrograde: false }, + { name: "Saturn", sign: "Capricorn", degree: 3.4, retrograde: true }, + ], +}; + +const MARKDOWN = [ + "#### D1 — Rashi Chart(本命盘)", + "", + 'engine', + "", + "```jyotish-chart", + JSON.stringify(SAMPLE_BLOCK), + "```", + "", + "```jyotish-chart", + "{not-json", + "```", +].join("\n"); + +test("markdown view draws the North Indian component and skips engine SVG HTML", () => { + const markup = renderToStaticMarkup( + React.createElement(PersonalReportMarkdownView, { markdown: MARKDOWN }), + ); + assert.match(markup, / `占星体${index + 1}`); const markup = render(dense); - assert.match(markup, /transform="translate\(0 100\)"/, "house 1 uses its own cell offset"); - assert.match(markup, /transform="translate\(300 0\)"/, "house 9 uses its own cell offset"); - assert.match(markup, /clip-path="url\(#.*-house-1\)"/, "house text is clipped to its own cell"); - assert.match(markup, /\+5 项/, "dense houses summarize overflow instead of painting twelve overlapping lines"); + // 原值 / 新值 / 原因 + // transform="translate(0 100)" / 1 宫多边形含 200,0 / 北印式是菱形宫,不再用 4×4 方格偏移 + // transform="translate(300 0)" / 9 宫多边形含 400,400 / 同上 + // +5 项 / +7 / 宫内最多 6 行(含折叠行),12 个占星体变成 5 行加 +7 + assert.match(markup, /points="200,0 300,100 200,200 100,100"/, "house 1 is the top diamond"); + assert.match(markup, /points="400,400 300,300 400,200"/, "house 9 is the lower-right triangle"); + assert.match(markup, /clip-path="url\(#.*-house-1\)"/, "house text is clipped to its own polygon"); + assert.match(markup, />\+7 { diff --git a/frontend/tests/report-chart-block.test.ts b/frontend/tests/report-chart-block.test.ts new file mode 100644 index 00000000..f322d7f8 --- /dev/null +++ b/frontend/tests/report-chart-block.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + CHART_SIZE, + HOUSE_NUMBERS, + HOUSE_POLYGONS, + pointInPolygon, + polygonArea, +} from "../src/lib/north-indian-chart-geometry.ts"; +import { + parseReportChartBlock, + planetDisplayLabel, + stripReportChartBlocks, + toNorthIndianChart, + type ReportChartBlock, +} from "../src/lib/report-chart-block.ts"; + +const fixture = JSON.parse( + readFileSync(new URL("./fixtures/report-chart-blocks-golden.json", import.meta.url), "utf8"), +) as { + sourceCommand: string; + blocks: ReportChartBlock[]; +}; + +function wrapMarkdown(blocks: readonly ReportChartBlock[]): string { + return blocks.map((block) => [ + `#### ${block.title}`, + "", + ``, + "", + "```jyotish-chart", + JSON.stringify(block), + "```", + "", + ].join("\n")).join("\n"); +} + +test("golden fixture records the engine command that produced it", () => { + assert.match(fixture.sourceCommand, /test_report_chart_block|build_professional_report_reference_packet|render_pl9_markdown/); +}); + +test("all 22 golden blocks parse", () => { + assert.equal(fixture.blocks.length, 22); + for (const raw of fixture.blocks) { + const parsed = parseReportChartBlock(JSON.stringify(raw)); + assert.ok(parsed, `block ${raw.id} should parse`); + } +}); + +test("D1 whole-sign houses start at the ascendant and wrap at 12", () => { + const d1 = fixture.blocks.find((block) => block.id === "D1"); + assert.ok(d1); + const chart = toNorthIndianChart(d1); + assert.equal(chart.houses[0]?.sign, d1.ascendant.sign); + const signs = [ + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", + ] as const; + const ascIndex = signs.indexOf(d1.ascendant.sign); + assert.equal(chart.houses[11]?.sign, signs[(ascIndex + 11) % 12]); +}); + +test("retrograde labels include 逆 after the planet letter", () => { + const sample = fixture.blocks.flatMap((block) => block.planets).find((planet) => planet.retrograde); + assert.ok(sample, "golden chart should contain at least one retrograde planet"); + const label = planetDisplayLabel(sample.name, sample.degree, true); + assert.match(label, /逆 \d+°$/); +}); + +test("malicious title is not drawn; parse still succeeds", () => { + const d1 = fixture.blocks.find((block) => block.id === "D1"); + assert.ok(d1); + const poisoned = parseReportChartBlock(JSON.stringify({ + ...d1, + title: '', + })); + assert.ok(poisoned); + const chart = toNorthIndianChart(poisoned); + const serialized = JSON.stringify(chart); + assert.doesNotMatch(serialized, / { + assert.equal(parseReportChartBlock("{"), null); + assert.equal(parseReportChartBlock(JSON.stringify({ ...fixture.blocks[0], extra: true })), null); + assert.equal(parseReportChartBlock(JSON.stringify({ ...fixture.blocks[0], ascendant: { ...fixture.blocks[0].ascendant, degree: 31 } })), null); + assert.equal(parseReportChartBlock(JSON.stringify({ + ...fixture.blocks[0], + planets: [{ name: "Uranus", sign: "Aries", degree: 1, retrograde: false }], + })), null); +}); + +test("stripReportChartBlocks removes fences and keeps engine SVGs", () => { + const markdown = wrapMarkdown(fixture.blocks); + const stripped = stripReportChartBlocks(markdown); + assert.doesNotMatch(stripped, /```jyotish-chart/); + assert.equal((stripped.match(/ { + const total = HOUSE_NUMBERS.reduce((sum, houseNumber) => sum + polygonArea(HOUSE_POLYGONS[houseNumber]), 0); + assert.ok(Math.abs(total - CHART_SIZE * CHART_SIZE) < 1e-6, `area ${total}`); + const step = 5; + for (let x = step; x < CHART_SIZE; x += step) { + for (let y = step; y < CHART_SIZE; y += step) { + const hits = HOUSE_NUMBERS.filter((houseNumber) => pointInPolygon({ x, y }, HOUSE_POLYGONS[houseNumber])); + assert.ok(hits.length <= 1, `point ${x},${y} in ${hits.join(",")}`); + } + } +}); diff --git a/scripts/chart_renderer.py b/scripts/chart_renderer.py index 3d08f948..ffa3e714 100644 --- a/scripts/chart_renderer.py +++ b/scripts/chart_renderer.py @@ -7,6 +7,7 @@ 支持:D1(本命)/D9(Navamsa)/D10(Dasamsa)等分盘 """ +import re from typing import Dict, List, Optional SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', @@ -55,6 +56,21 @@ GRID_OFFSET_X = 50 GRID_OFFSET_Y = 50 SVG_WIDTH = GRID_OFFSET_X * 2 + CELL_SIZE * 4 SVG_HEIGHT = GRID_OFFSET_Y * 2 + CELL_SIZE * 4 + 60 +_D_ID_RE = re.compile(r"\b(D\d{1,3})\b") + + +def _center_caption(title: str) -> tuple[str, str]: + """Keep D1 as ``Rasi Chart / (D1)``; other titles follow the heading id.""" + moon = bool(re.search(r"Moon", title, re.I)) + match = _D_ID_RE.search(title or "") + chart_id = match.group(1) if match else None + if chart_id == "D1" and not moon: + return "Rasi Chart", "(D1)" + if moon: + return "Chart", "(Moon)" + if chart_id: + return "Chart", f"({chart_id})" + return "Chart", "" def render_south_indian_chart(planets: Dict, asc_sign: str, title: str = "D1 — Rashi Chart") -> str: @@ -117,8 +133,10 @@ def render_south_indian_chart(planets: Dict, asc_sign: str, title: str = "D1 — # 中心区域(传统上写星盘信息) cx = GRID_OFFSET_X + 1.5 * CELL_SIZE cy = GRID_OFFSET_Y + 1.5 * CELL_SIZE - lines.append(f'Rasi Chart') - lines.append(f'(D1)') + caption, chart_id = _center_caption(title) + lines.append(f'{caption}') + if chart_id: + lines.append(f'{chart_id}') lines.append('') return '\n'.join(lines) diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index d0bb1a01..609e6ff1 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -4181,23 +4181,45 @@ def render_pl9_markdown(packet: dict) -> str: chart_planets[planet_name] = {'sign': sign, 'degree': degree or 0} return chart_planets - def _render_south_chart(positions: dict, ascendant_row: dict, title: str) -> str | None: + def _render_south_chart( + positions: dict, + ascendant_row: dict, + title: str, + chart_id: str | None = None, + ) -> str | None: chart_planets = _chart_planets_from_positions(positions) asc_sign = ascendant_row.get('sign') if isinstance(ascendant_row, dict) else None if not chart_planets or not asc_sign: return None try: from chart_renderer import render_south_indian_chart - return render_south_indian_chart(chart_planets, asc_sign, title) + svg = render_south_indian_chart(chart_planets, asc_sign, title) except Exception as exc: return f'_图盘生成失败:{exc}_' + from report_chart_block import PLANET_ORDER, build_chart_block, resolve_chart_id + core_chart = packet.get('core_chart') if isinstance(packet.get('core_chart'), dict) else {} + natal_planets = core_chart.get('planets') if isinstance(core_chart.get('planets'), dict) else {} + retrograde_by_planet = { + name: bool(row.get('retrograde')) if isinstance(row, dict) else False + for name in PLANET_ORDER + for row in [natal_planets.get(name)] + } + block = build_chart_block( + resolve_chart_id(chart_id, title), + title, + positions if isinstance(positions, dict) else {}, + ascendant_row if isinstance(ascendant_row, dict) else {}, + retrograde_by_planet, + ) + return f"{svg}\n\n{block}" def _d1_chart_svg() -> str | None: core_chart = packet.get('core_chart') if isinstance(packet.get('core_chart'), dict) else {} return _render_south_chart( core_chart.get('planets') if isinstance(core_chart.get('planets'), dict) else {}, core_chart.get('ascendant') if isinstance(core_chart.get('ascendant'), dict) else {}, - 'D1 — Rashi Chart (本命盘)', + 'D1 — Rashi Chart(本命盘)', + 'D1', ) def _varga_full_sheet() -> dict: @@ -4214,10 +4236,10 @@ def render_pl9_markdown(packet: dict) -> str: if not chart: return None ascendant_row = chart.get('Ascendant') if isinstance(chart.get('Ascendant'), dict) else chart.get('Asc') if isinstance(chart.get('Asc'), dict) else {} - return _render_south_chart(chart, ascendant_row, title) + return _render_south_chart(chart, ascendant_row, title, chart_key.split('_', 1)[0]) def _d9_chart_svg() -> str | None: - return _varga_chart_svg('D9_Navamsa', 'D9 — Navamsa (婚盘)') or _varga_chart_svg('D9', 'D9 — Navamsa (婚盘)') + return _varga_chart_svg('D9_Navamsa', 'D9 — Navamsa(婚盘)') or _varga_chart_svg('D9', 'D9 — Navamsa(婚盘)') def _moon_chart_section() -> list[str]: d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {} @@ -4227,7 +4249,7 @@ def render_pl9_markdown(packet: dict) -> str: planets = raw.get('planets') if isinstance(raw.get('planets'), dict) else {} if not reference or not planets: return [] - svg = _render_south_chart(planets, reference, 'Moon Chart(月亮参考盘)') + svg = _render_south_chart(planets, reference, 'Moon Chart(月亮参考盘)', 'MOON') out = [ '#### Moon Chart(月亮参考盘)', '', diff --git a/scripts/report_chart_block.py b/scripts/report_chart_block.py new file mode 100644 index 00000000..cda12905 --- /dev/null +++ b/scripts/report_chart_block.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Structured North-Indian chart fences for longform report Markdown. + +Engine SVG stays in the Markdown for exporters. The report page never renders +that HTML; it reads the ``jyotish-chart`` fence beside each SVG. +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +PLANET_ORDER = ( + "Sun", + "Moon", + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn", + "Rahu", + "Ketu", +) +SIGNS = ( + "Aries", + "Taurus", + "Gemini", + "Cancer", + "Leo", + "Virgo", + "Libra", + "Scorpio", + "Sagittarius", + "Capricorn", + "Aquarius", + "Pisces", +) + +CHART_ID_RE = re.compile(r"^(D\d{1,3}|MOON)$") +FENCE_RE = re.compile(r"```jyotish-chart\n(.*)\n```") +_D_ID_RE = re.compile(r"\b(D\d{1,3})\b") + + +def resolve_chart_id(chart_id: str | None, title: str) -> str: + """Prefer an explicit varga prefix (``D2_Hora`` → ``D2``), else parse the title.""" + if chart_id: + prefix = str(chart_id).split("_", 1)[0] + if CHART_ID_RE.match(prefix): + return prefix + if re.search(r"Moon", title, re.I): + return "MOON" + match = _D_ID_RE.search(title) + if match and CHART_ID_RE.match(match.group(1)): + return match.group(1) + raise ValueError("chart id missing") + + +def _degree_in_sign(row: dict[str, Any]) -> float: + for field in ("degree_in_sign", "degree_in_sign_raw", "longitude_in_sign"): + value = row.get(field) + if isinstance(value, (int, float)): + degree = float(value) % 30.0 + if degree < 0: + degree += 30.0 + return round(degree, 2) + for field in ("degree", "degree_raw", "longitude", "lon"): + value = row.get(field) + if isinstance(value, (int, float)): + degree = float(value) % 30.0 + if degree < 0: + degree += 30.0 + return round(degree, 2) + return 0.0 + + +def _sign_name(row: dict[str, Any]) -> str | None: + sign = row.get("sign") or row.get("rashi") or row.get("sign_name") + if sign in SIGNS: + return str(sign) + return None + + +def build_chart_block( + chart_id: str, + title: str, + positions: dict, + ascendant_row: dict, + retrograde_by_planet: dict[str, bool], +) -> str: + """Return a one-line JSON fence. Never interpolates user-supplied strings.""" + resolved_id = resolve_chart_id(chart_id, title) + if not isinstance(ascendant_row, dict): + ascendant_row = {} + asc_sign = _sign_name(ascendant_row) + if not asc_sign: + raise ValueError("ascendant sign missing") + planets: list[dict[str, Any]] = [] + source = positions if isinstance(positions, dict) else {} + for name in PLANET_ORDER: + row = source.get(name) + if not isinstance(row, dict): + continue + sign = _sign_name(row) + if not sign: + continue + planets.append( + { + "name": name, + "sign": sign, + "degree": _degree_in_sign(row), + "retrograde": bool(retrograde_by_planet.get(name, False)), + } + ) + if len(planets) >= 9: + break + payload = { + "version": 1, + "id": resolved_id, + "title": str(title), + "layout": "north", + "ascendant": { + "sign": asc_sign, + "degree": _degree_in_sign(ascendant_row), + }, + "planets": planets, + } + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + return f"```jyotish-chart\n{body}\n```" + + +def extract_chart_blocks(markdown: str) -> list[dict[str, Any]]: + blocks: list[dict[str, Any]] = [] + for match in FENCE_RE.finditer(markdown or ""): + blocks.append(json.loads(match.group(1))) + return blocks diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 0c793996..2a194d56 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -83,6 +83,8 @@ CORE_PYTEST_TARGETS = [ "tests/test_consultation_workflow_birth_time_sensitivity.py", "tests/test_report_longform_parity.py", "tests/test_report_longform_gaps2.py", + # 22 SVG fences beside each longform chart; skipHtml dropped the HTML (BUG-607). + "tests/test_report_chart_block.py", "tests/test_timing_precision_contract.py", "tests/test_flexible_birth_time_report_contract.py", "tests/test_mcp_strict_workflow_finance.py", diff --git a/tests/test_report_chart_block.py b/tests/test_report_chart_block.py new file mode 100644 index 00000000..ab367de2 --- /dev/null +++ b/tests/test_report_chart_block.py @@ -0,0 +1,197 @@ +"""Structured chart fences next to each engine SVG in longform Markdown.""" + +from __future__ import annotations + +import json +from functools import lru_cache +from types import SimpleNamespace + +from report_chart_block import SIGNS, extract_chart_blocks +from scripts.jyotish_engine import ( + SIGNS as ENGINE_SIGNS, + _compute_chart_from_args, + build_professional_report_reference_packet, + cmd_varga_full, + render_pl9_markdown, +) + +PLACE = "FictionalHarborTownship" +PLANET_ORDER = ( + "Sun", + "Moon", + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn", + "Rahu", + "Ketu", +) + + +def _fictional_args() -> SimpleNamespace: + return SimpleNamespace( + year=1990, + month=6, + day=15, + hour=12, + minute=0, + second=0, + lat=39.9042, + lon=116.4074, + tz=8.0, + ayanamsa="raman", + node_mode="mean", + divisions=None, + custom=None, + composite=None, + variant=None, + today="2026-09-09", + target_year=2026, + age=36, + visual_chart_observations=None, + startrack_language_bridge=False, + birth_time_accuracy="confirmed", + position_mode="legacy", + pack=[], + packs=None, + ) + + +def _moon_chart(planets: dict) -> dict: + longitudes: dict[str, float] = {} + for name, row in planets.items(): + if not isinstance(row, dict): + continue + value = row.get("degree_raw", row.get("degree")) + if isinstance(value, (int, float)): + longitudes[name] = float(value) + moon_lon = longitudes["Moon"] % 360 + moon_sign_idx = int(moon_lon // 30) % 12 + return { + "status": "computed", + "raw": { + "Ascendant": { + "sign": ENGINE_SIGNS[moon_sign_idx], + "longitude": round(moon_lon, 6), + "reference": "Moon", + }, + "planets": { + name: { + "longitude": round(lon % 360, 6), + "sign": ENGINE_SIGNS[int(lon % 360 // 30) % 12], + "house_from_moon": ((int(lon % 360 // 30) - moon_sign_idx) % 12) + 1, + } + for name, lon in longitudes.items() + }, + }, + } + + +@lru_cache(maxsize=1) +def _golden() -> dict: + args = _fictional_args() + chart, _asc_idx, _jd, _ayanamsa = _compute_chart_from_args(args) + assert isinstance(chart, dict) + varga_full = cmd_varga_full(args) + full_reading = { + "version": "4.4.0-full-reading", + "birth_info": { + "date": "1990-06-15", + "time": "12:00:00", + "hour": 12, + "minute": 0, + "second": 0, + "lat": args.lat, + "lon": args.lon, + "tz": "UTC+8.0", + "place": PLACE, + }, + "ascendant": chart.get("ascendant"), + "planets": chart.get("planets"), + "houses": chart.get("houses"), + "chart": chart, + "modules": { + "varga_full": varga_full, + "moon_chart": _moon_chart(chart.get("planets") or {}), + }, + "errors": [], + "warnings": [], + } + packet = build_professional_report_reference_packet(full_reading, args) + markdown = render_pl9_markdown(packet) + return {"packet": packet, "markdown": markdown} + + +def _svg_after_heading(markdown: str, heading: str) -> str: + rest = markdown[markdown.index(heading):] + start = rest.index("") + len("") + return rest[start:end] + + +def test_fence_count_matches_svg_and_is_twenty_two() -> None: + markdown = _golden()["markdown"] + assert markdown.count(" None: + blocks = extract_chart_blocks(_golden()["markdown"]) + assert len(blocks) == 22 + for block in blocks: + assert block["version"] == 1 + assert block["layout"] == "north" + assert block["id"] == "MOON" or block["id"].startswith("D") + assert block["ascendant"]["sign"] in SIGNS + assert 0 <= block["ascendant"]["degree"] < 30 + assert len(block["planets"]) <= 9 + for planet in block["planets"]: + assert planet["name"] in PLANET_ORDER + assert planet["sign"] in SIGNS + assert 0 <= planet["degree"] < 30 + assert isinstance(planet["retrograde"], bool) + + +def test_d1_ascendant_matches_core_chart() -> None: + golden = _golden() + core = golden["packet"]["core_chart"] + d1 = next(block for block in extract_chart_blocks(golden["markdown"]) if block["id"] == "D1") + assert d1["ascendant"]["sign"] == core["ascendant"]["sign"] + natal = core["planets"] + for planet in d1["planets"]: + row = natal.get(planet["name"]) or {} + assert planet["retrograde"] is bool(row.get("retrograde")) + + +def test_every_block_uses_natal_retrograde() -> None: + golden = _golden() + natal = golden["packet"]["core_chart"]["planets"] + for block in extract_chart_blocks(golden["markdown"]): + for planet in block["planets"]: + row = natal.get(planet["name"]) or {} + assert planet["retrograde"] is bool(row.get("retrograde")) + + +def test_chart_blocks_omit_birthplace_label() -> None: + for block in extract_chart_blocks(_golden()["markdown"]): + dumped = json.dumps(block, ensure_ascii=False) + assert PLACE not in dumped + assert PLACE not in block["title"] + + +def test_d9_svg_center_label_follows_title() -> None: + markdown = _golden()["markdown"] + d9_svg = _svg_after_heading(markdown, "#### D9 — Navamsa(婚盘)") + assert "(D9)" in d9_svg + assert "(D1)" not in d9_svg + d1_svg = _svg_after_heading(markdown, "#### D1 — Rashi Chart(本命盘)") + assert "(D1)" in d1_svg + assert "Rasi Chart" in d1_svg + + +def test_chart_block_suite_is_on_the_quick_quality_gate() -> None: + from scripts.run_quality_gate import CORE_PYTEST_TARGETS + + assert "tests/test_report_chart_block.py" in CORE_PYTEST_TARGETS