fix(report): group chart cards in the existing grid and stop remounting on scroll (BUG-616, BUG-617)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-09 18:44:14 +08:00
co-authored by Cursor
parent 584a56bd81
commit 87daffe251
10 changed files with 449 additions and 66 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志 # 印度占星 Skill 更新日志
## 2026-09-09 — 长报告星盘改成两栏卡片,滚动时不再整页重建
个人长报告里的本命盘、婚盘、月亮盘和 Vargas I 不再叠在同一块:每张盘和它的标题进一张卡片,连续的卡片走原来的两栏网格;窄屏和打印仍是单栏。滚动目录高亮时,正文里的图不会被拆掉重画。导出的 `.md` 仍带引擎 SVG、不含网页用的 JSON 围栏。Skill 版本不变。
## 2026-09-09 — 首页「深入看今日」算完会写出三节回答,思考区不再漏英文词渣 ## 2026-09-09 — 首页「深入看今日」算完会写出三节回答,思考区不再漏英文词渣
从首页点「深入看今日」时,按「时运」计算,不再被改成「综合」。正文按三节写:今日趋势、适合推进 / 需要避开、一个行动。某一节没写出字会再试一次;分段写作时不能再调排盘工具。思考区按整句过滤,不会再出现缺词英文或 `run-jyotish-` 残段。空回答仍然不扣点。Skill 版本不变。 从首页点「深入看今日」时,按「时运」计算,不再被改成「综合」。正文按三节写:今日趋势、适合推进 / 需要避开、一个行动。某一节没写出字会再试一次;分段写作时不能再调排盘工具。思考区按整句过滤,不会再出现缺词英文或 `run-jyotish-` 残段。空回答仍然不扣点。Skill 版本不变。
+33 -1
View File
@@ -9426,7 +9426,7 @@
- 修复:引擎在每张 SVG 旁追加 `jyotish-chart` 围栏 JSON;阅读页只认围栏,zod 校验后用北印式菱形组件自绘。导出 `.md` 前剥掉围栏,保留 SVG 给外部阅读器。 - 修复:引擎在每张 SVG 旁追加 `jyotish-chart` 围栏 JSON;阅读页只认围栏,zod 校验后用北印式菱形组件自绘。导出 `.md` 前剥掉围栏,保留 SVG 给外部阅读器。
- 验证:`tests/test_report_chart_block.py` 7 项(22 块围栏 == 22 个 `<svg>`,度数/星座/逆行与 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"`,坏块显示「图盘数据无效」且无 `<script`)。全量 `npm test` 2975 tests / 2971 pass / 4 fail,失败 4 条均为 Docker Postgres fixture,与图盘无关。`tsc --noEmit` 0 错;`next build --webpack``/` 仍为 Static。 - 验证:`tests/test_report_chart_block.py` 7 项(22 块围栏 == 22 个 `<svg>`,度数/星座/逆行与 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"`,坏块显示「图盘数据无效」且无 `<script`)。全量 `npm test` 2975 tests / 2971 pass / 4 fail,失败 4 条均为 Docker Postgres fixture,与图盘无关。`tsc --noEmit` 0 错;`next build --webpack``/` 仍为 Static。
- 防复发:Markdown 阅读页继续 `skipHtml` / 禁止 `rehype-raw`;图盘文本只来自白名单映射。上述测试锁住围栏块数与渲染通路。 - 防复发:Markdown 阅读页继续 `skipHtml` / 禁止 `rehype-raw`;图盘文本只来自白名单映射。上述测试锁住围栏块数与渲染通路。
- 相关记录:`TASK-report-md-page-20260906.md``TASK-report-chart-render-20260909.md` - 相关记录:`TASK-report-md-page-20260906.md``TASK-report-chart-render-20260909.md`、BUG-616、BUG-617
- 复发自:无(09-06 直渲回归,不是旧 BUG 编号复发) - 复发自:无(09-06 直渲回归,不是旧 BUG 编号复发)
- 修复版本:待发布 - 修复版本:待发布
@@ -9525,3 +9525,35 @@
- 相关记录:BUG-612(同一次事故的空回答)、`TASK-consultation-daily-empty-answer-20260909.md` - 相关记录:BUG-612(同一次事故的空回答)、`TASK-consultation-daily-empty-answer-20260909.md`
- 复发自:无 - 复发自:无
- 修复版本:待发布 - 修复版本:待发布
## BUG-616 | 报告页 22 张北印盘叠在同一位置
- 状态:resolved
- 首次发现:2026-09-09
- 最近更新:2026-09-09
- 影响面:`/personal-report` Markdown 阅读页、`personal-report-markdown-view.tsx``globals.css``report-chart-grid-rehype.ts`
- 用户现象:「Birth Chart / Vargas」里 D1 / D9 / Moon 三张盘叠在同一块;Vargas I 的 19 个 `####` 标题挤在左上角,19 张盘全部叠在一起,「Shodashvarga 星座总表」和后面的表也压在盘上。
- 触发条件:打开含 `jyotish-chart` 围栏的个人长报告详情页,宽于 720px 的桌面视口。
- 根因:BUG-607 用浮动 + `margin-left: -50%` 在扁平 Markdown 兄弟节点上凑两栏。负 margin 让每个 figure 的 margin box 宽度为 0,浮动算法认为它不占横向空间,连续多对标题+盘必然重叠。`renderToStaticMarkup` 只看 DOM,看不见布局,所以当时的 golden 全绿。
- 修复:删掉 `.personal-report-chart-*` 上的 float / 负 margin。rehype 插件把「`h4`D… / Moon Chart+ 可选一段说明 + `pre > code.language-jyotish-chart`」包进 `.personal-report-chart-card`,相邻卡片再包进既有的 `.personal-report-chart-grid`(单张加 `is-single`)。不放开 `skipHtml`,不引入 `rehype-raw`
- 验证:`frontend/tests/personal-report-markdown-view.test.tsx`:3 对连续标题+围栏(第 3 对夹一段 `p`)得到 1 个 grid / 3 张卡,卡内顺序为 `h4` →(`p`)→ `figure`,后面的普通 `h4` 不在卡内;单对带 `is-single`golden 22 张 `figure` 全在卡内;`globals.css` 中同时含 `personal-report-chart``float:` / `margin-left: -50%` 的规则为 0。
- 防复发:布局合同走 DOM 结构(grid/card 包裹)和 CSS 禁令,不只看「有没有 `<svg>`」。
- 相关记录:BUG-607、`TASK-report-chart-layout-fix-20260909.md`
- 复发自:无(BUG-607 的两栏浮动方案回归,不是「标题下没图」复发)
- 修复版本:待发布
## BUG-617 | 滚动报告时整篇 Markdown 卸载重建,页面卡顿
- 状态:resolved
- 首次发现:2026-09-09
- 最近更新:2026-09-09
- 影响面:`personal-report-markdown-view.tsx` 目录高亮与文章树
- 用户现象:长报告页(尤其 Vargas I 19 张盘揭开后)一滚动就明显卡,像整页在闪。
- 触发条件:打开含星盘围栏的长报告,滚动使目录高亮从一个 `h2`/`h3` 切到下一个。
- 根因:`activeId` 放在 `PersonalReportMarkdownView` 顶层,IntersectionObserver 每次切换都 `setActiveId``markdownComponents()` 每渲染新建一套 `h2`/`h3`/`h4`/`pre` 函数;React 按元素 type 引用比较,type 变了就卸载重建整棵子树。09-06 Markdown 直渲时已是这个结构,当时没有 SVG,代价没被感知;22 张盘进来后每次滚过一个标题就销毁再创建全部多边形和 `<text>`
- 修复:目录高亮下沉到 `ReportToc``PersonalReportMarkdownView` 不再持有滚动态。lead 与 `LazyMarkdownSection``renderMarkdown` 结果整树 `useMemo``LazyMarkdownSection` 本身 `memo()`
- 验证:源码合同:`renderMarkdown(` 只在 `useMemo` 内调用;`PersonalReportMarkdownView` 函数体没有 `useState`。本仓测试是 `renderToStaticMarkup`,数不了重渲染次数;真机条目写在 `docs/testing/report-chart-render-20260909.md` 第 7 节。
- 防复发:文章树不得订阅目录高亮 state。`components` 映射可以每次 `renderMarkdown` 新建(id 去重 cursor 需要),但调用点必须被 `useMemo` 包住。
- 相关记录:BUG-616、`TASK-report-md-page-20260906.md``TASK-report-chart-layout-fix-20260909.md`
- 复发自:无(09-06 直渲结构在星盘进入后才变成可感知卡顿)
- 修复版本:待发布
@@ -0,0 +1,91 @@
# PROGRESS · 报告页星盘重叠 + 滚动重建(2026-09-09
- 工作树:`.worktrees/report-chart-layout-fix-20260909`
- 分支:`codex/report-chart-layout-fix-20260909`track `origin/staging`
- 基线:`origin/staging` @ `584a56bd`(任务书本身;代码基线 `17b36f3a`
- BUG**BUG-616**、**BUG-617**
- 未改 `.gitea/workflows/**``frontend/src/app/page.tsx`、下载剥围栏;不提升 `main`,未 push
## 做了什么
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| 1 两栏成组 | 完成 | `report-chart-grid-rehype.ts` 把 D… / Moon Chart 标题 + 可选 `p` + `jyotish-chart` 围栏包进 `.personal-report-chart-card`,相邻卡进既有 `.personal-report-chart-grid`;删 float / `margin-left: -50%` |
| 2 滚动不重建 | 完成 | `ReportToc` 持有 `activeId` 与 observerlead / `LazyMarkdownSection``renderMarkdown``useMemo` 内;`LazyMarkdownSection` `memo()` |
| 3 记录 | 完成 | BUG-616/617、CHANGELOG、DESIGN、走查清单第 7 节、本文件;任务板改「待验收」 |
没有砍让步项。rehype 两栏一次做成,未退到 D2 单栏。
## 口径(与任务书一致)
- 两栏保留,不再用浮动凑。
- `skipHtml` / 禁止 `rehype-raw` / 不新增 npm 依赖。
- 下载 `.md` 仍剥围栏、保留标题。
- 本仓测试无 DOM 运行时,重渲染次数不能自动验;真机两条写在走查清单第 7 节。
## 组件树(改动前后)
改前:
```
PersonalReportMarkdownView
useState(activeId) + IntersectionObserver ← 目录一变,整树重渲染
renderMarkdown(lead) ← 每次新建 components,子树卸载
LazyMarkdownSection (无 memo)
renderMarkdown(section) ← 同上
```
改后:
```
PersonalReportMarkdownView ← 无 useState,只随 markdown 变
ReportToc
useState(activeId) + IntersectionObserver
TocList × 2
article
lead = useMemo(renderMarkdown)
memo(LazyMarkdownSection)
content = useMemo(renderMarkdown | h2 占位)
```
滚动只重绘 `ReportToc`。文章树里的 SVG 保持同一组 DOM 节点(真机第 7 节)。
## 偏离
1. **两栏折断点跟既有 document-view grid,是 760px 而不是任务书正文里的 860px。** `globals.css``.personal-report-chart-grid` 的单栏媒体查询一直是 `max-width: 760px`;860px 只折叠 TOC。未改这个既有数字。
2. **rehype 插件导出了 `groupReportChartNodes`。** 给同一文件的工厂函数用,测试仍走 `PersonalReportMarkdownView` 的静态标记,没有另开 hast 单测。
## 测试数字
| 项 | 本轮实测 | 结论 |
| --- | --- | --- |
| `tsc --noEmit` | 0 错 | 通过 |
| `npm run lint` | 0 error / 108 warning | warning 数与 BUG-607 收工相同;新文件无 error |
| `tsx --test tests/personal-report-markdown-view.test.tsx` | 6 passed | 原 1 条 + 本单 5 条 |
| `globals.css` gzip | 开工 `35791` B → 工作树 `35612` B | 179 B / 0.50%,在 ±2% 内 |
| `next build --webpack` `/` | `○ Static` | 交叉 `node_modules` 符号链接,与 BUG-607 相同改用 webpack |
| 首页 CSS gzip(构建产物) | 最大块 `a083d2bd2a5e75cb.css` = 36,083 B | 相对 BUG-607 的 35,779 B 为 +0.85%,在 ±2% 内 |
| `npm test` tests | 3008 | 高于 BUG-607 的 2975staging 后来加了测试,本单 +5 |
| `npm test` pass | 2997 | |
| `npm test` fail | 11 | 全是 Docker Postgres fixture / 迁移 / 超时 / onboarding deadline flake,与图盘无关 |
| `npm test` skipped | 0 | Docker 可用 |
11 条失败(均非本轮图盘代码):
1. `admin-database.test.ts``database migration failed`
2. `database-admin-account-reset.test.ts` — 同上
3. `database-admin-owner-recovery.test.ts` — 同上
4. `database-billing-audit.test.ts` — 同上(billing order adjustments…)
5. `database-billing-product-admin.test.ts` — 300s 超时(当时并行跑过 `next build`
6. `database-migrate.test.ts` — 300s 超时(同上)
7. `database-self-hosted-identity.test.ts` — Better Auth OTP fixture
8. `onboarding-route.test.ts``slow Agent generation``deadline` 当成了失败(flake
911. `rectification-v9-database.test.ts` 三条 — `database migration failed`
无 Docker 基线 28 条本轮未复跑(Docker 可用)。图盘相关 6 条 markdown-view + 既有 `report-chart-block.test.ts` 全绿。
## 没做的
- **没有在浏览器里打开过一份真实 `/reports/<id>`。** 无登录态。走查清单第 4 节和第 7 节待真机。
- 未 push。代码进 staging 会走 `Staging Backend Quality Gate`
- 收工时 `origin/staging` 已含 BUG-614615、BUG-618620;合并写在本提交之后。
+1 -1
View File
@@ -121,7 +121,7 @@
| `TASK-report-list-500-20260907.md`(仓库根) | `PROGRESS-report-list-500-20260907.md` | 列表 PostgREST JSON 路径 500 | 待验收 | `b466a6fc`BUG-574 | | `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 | | — | `PROGRESS-report-progress-20260909.md` | 生成等待屏只有 spinner 与秒表:后端 `progressPercent` / `progressPhase` 与分章行已产出,前端解析后一字未渲染,且分章行在 `generating` 时根本不出服务端。改为按章分格进度条 + 章节清单,停滞 90 秒改「用时较长,仍在写」;不画百分比条、不做插值动画、不报预计剩余 | 已验收 | `848e39e6``5565b632`BUG-601 |
| `TASK-report-chart-render-20260909.md` | `PROGRESS-report-chart-render-20260909.md` | 报告页所有分盘标题下空白:引擎 22 张 `<svg>``skipHtml` 丢弃(09-06 Markdown 直渲回归)。引擎旁加 ```jyotish-chart 围栏 JSON,前端 zod 校验后用自绘北印式组件全部渲染;不放开原始 HTML | 已验收(2fdcb14f):tsc/lint 0 error、chart 测试 29/29、`/` Static、gzip +0.96%;围栏构造在 try 外的 P2 并入 `TASK-upstream-sync2-fix-20260909.md` 任务 3 | `codex/report-chart-render-20260909`BUG-607 | | `TASK-report-chart-render-20260909.md` | `PROGRESS-report-chart-render-20260909.md` | 报告页所有分盘标题下空白:引擎 22 张 `<svg>``skipHtml` 丢弃(09-06 Markdown 直渲回归)。引擎旁加 ```jyotish-chart 围栏 JSON,前端 zod 校验后用自绘北印式组件全部渲染;不放开原始 HTML | 已验收(2fdcb14f):tsc/lint 0 error、chart 测试 29/29、`/` Static、gzip +0.96%;围栏构造在 try 外的 P2 并入 `TASK-upstream-sync2-fix-20260909.md` 任务 3 | `codex/report-chart-render-20260909`BUG-607 |
| `TASK-report-chart-layout-fix-20260909.md` | | 星盘渲染回归修复:22 张北印盘全部重叠(浮动 + 负 margin 凑两栏对连续多对无效)+ 滚动时 `components` 每渲染新建导致整篇报告卸载重建、页面卡;改 rehype 成组进既有两栏 grid、文章树 `useMemo`、目录状态下沉 | 待领取 | 分支 `codex/report-chart-layout-fix-20260909`BUG-616/617 | | `TASK-report-chart-layout-fix-20260909.md` | `PROGRESS-report-chart-layout-fix-20260909.md` | 星盘渲染回归修复:22 张北印盘全部重叠(浮动 + 负 margin 凑两栏对连续多对无效)+ 滚动时 `components` 每渲染新建导致整篇报告卸载重建、页面卡;改 rehype 成组进既有两栏 grid、文章树 `useMemo`、目录状态下沉 | 待验收 | `codex/report-chart-layout-fix-20260909`BUG-616/617 |
### 前端基础与工程 ### 前端基础与工程
+10 -3
View File
@@ -25,8 +25,8 @@
## 4. 手机与打印 ## 4. 手机与打印
- [ ] iPhone Safari 竖屏:图单列、居中,不撑出左右滚动(宽表除外)。 - [ ] iPhone Safari 竖屏:图单列,不撑出左右滚动(宽表除外),标题在卡内、图在标题下,互不重叠
- [ ] 桌面 ≥720pxVargas I 的图是两列。 - [ ] 桌面宽于 760pxVargas I 的图是两列卡片,标题在各自卡顶,没有叠盘、没有标题堆成一列
- [ ] 打印预览:图仍是单列,不会变成两列挤在纸上。 - [ ] 打印预览:图仍是单列,不会变成两列挤在纸上。
## 5. 导出 `.md` ## 5. 导出 `.md`
@@ -37,4 +37,11 @@
- [ ] 打开一份 09-06 之后、本修复之前生成的报告:标题还在,不报错,不出现「图盘数据无效」(因为根本没有围栏块)。图可以没有。 - [ ] 打开一份 09-06 之后、本修复之前生成的报告:标题还在,不报错,不出现「图盘数据无效」(因为根本没有围栏块)。图可以没有。
未打勾的项写成「未验证」。发现南印度方格、空白标题、导出文件里大段 JSON,或暗色主题下图看不见,把现象和报告页截图贴回。 ## 7. 滚动不重建(BUG-617
本仓前端测试是 `renderToStaticMarkup`,没有 DOM 运行时,数不了 React 重渲染。下面两条必须在真机(Chrome)上对照一份 Vargas I 已全部揭开的新报告。
- [ ] Vargas I 全部揭开后连续滚动 10 秒:Chrome Performance 没有 >100ms 的长帧。
- [ ] Elements 面板选中 D1 的 `<svg>` 之后滚动、目录高亮切换:选中不丢,该 `<svg>` DOM 节点还是同一个。
未打勾的项写成「未验证」。发现南印度方格、空白标题、导出文件里大段 JSON、暗色主题下图看不见、盘重叠,或滚动时 Elements 里 SVG 被替换,把现象和报告页截图贴回。
+1 -1
View File
@@ -357,7 +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 “旧版本报告,请重新生成”. - **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. - **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. - **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 112. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. - **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 112. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`.
- **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge. - **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. - **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. - **Width:** 1120px for the reader chrome so the TOC rail can sit beside the article; the TOC stacks into a drawer below 860px.
+17 -36
View File
@@ -3832,31 +3832,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-title-sm); font-size: var(--type-title-sm);
font-weight: 500; font-weight: 500;
} }
@media (min-width: 720px) { .personal-report-md-article .personal-report-chart-grid {
/* Charts live in the eager 摘要 lead, not only later lazy H2 sections. */ margin: var(--space-5) 0 var(--space-6);
.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) { .personal-report-md-article .personal-report-chart-card h4 {
float: left; margin: 0;
width: 50%; font-size: var(--type-title-sm);
box-sizing: border-box; font-weight: 500;
padding-inline: var(--space-3); }
margin-top: var(--space-5); .personal-report-chart-card .personal-report-chart-figure {
margin-bottom: 0; max-width: none;
} margin: var(--space-3) 0 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) { @media (max-width: 860px) {
.personal-report-md-layout { grid-template-columns: 1fr; } .personal-report-md-layout { grid-template-columns: 1fr; }
@@ -4418,23 +4404,18 @@ 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-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-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-chart-svg { max-width: none; margin-top: 3mm; }
.personal-report-md-article .personal-report-chart-figure { .personal-report-md-article .personal-report-chart-card .personal-report-chart-figure {
float: none; max-width: none;
width: auto; width: auto;
max-width: 360px; margin: 3mm 0 0;
margin-left: auto;
margin-right: auto;
padding-top: 0; padding-top: 0;
break-inside: avoid; break-inside: avoid;
} }
.personal-report-md-article h4.personal-report-chart-heading { .personal-report-md-article .personal-report-chart-card h4 {
float: none; max-width: none;
width: auto;
margin-left: auto;
margin-right: auto;
max-width: 360px;
padding-inline: 0; padding-inline: 0;
} }
.personal-report-chart-card { break-inside: avoid; }
.personal-report-chart-grid, .personal-report-chart-grid,
.personal-report-chart-grid.is-single { grid-template-columns: 1fr; } .personal-report-chart-grid.is-single { grid-template-columns: 1fr; }
.personal-report-chart-grid.is-single { max-width: 118mm; } .personal-report-chart-grid.is-single { max-width: 118mm; }
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown"; import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
@@ -10,6 +10,7 @@ import {
parseReportChartBlock, parseReportChartBlock,
toNorthIndianChart, toNorthIndianChart,
} from "@/lib/report-chart-block"; } from "@/lib/report-chart-block";
import { reportChartGrid } from "@/lib/report-chart-grid-rehype";
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg"; import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
import { import {
buildLongformOutline, buildLongformOutline,
@@ -90,6 +91,7 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[])
<ReactMarkdown <ReactMarkdown
components={markdownComponents(headings)} components={markdownComponents(headings)}
disallowedElements={["script", "iframe", "object", "embed", "img"]} disallowedElements={["script", "iframe", "object", "embed", "img"]}
rehypePlugins={[reportChartGrid]}
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
skipHtml skipHtml
unwrapDisallowed unwrapDisallowed
@@ -100,7 +102,7 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[])
); );
} }
function LazyMarkdownSection({ const LazyMarkdownSection = memo(function LazyMarkdownSection({
section, section,
}: { }: {
section: LongformSection; section: LongformSection;
@@ -120,21 +122,24 @@ function LazyMarkdownSection({
observer.observe(ref.current); observer.observe(ref.current);
return () => observer.disconnect(); return () => observer.disconnect();
}, [visible]); }, [visible]);
const content = useMemo(
() => (visible ? renderMarkdown(section.markdown, section.headings) : (
<h2 id={section.id}>{section.title}</h2>
)),
[visible, section],
);
return ( return (
<section ref={ref} className="personal-report-md-section"> <section ref={ref} className="personal-report-md-section">
{visible ? renderMarkdown(section.markdown, section.headings) : ( {content}
<h2 id={section.id}>{section.title}</h2>
)}
</section> </section>
); );
} });
export function PersonalReportMarkdownView({ markdown }: { markdown: string }) { function ReportToc({ headings }: { headings: readonly LongformHeading[] }) {
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]); const [activeId, setActiveId] = useState(headings[0]?.id ?? "");
const [activeId, setActiveId] = useState(outline.headings[0]?.id ?? "");
useEffect(() => { useEffect(() => {
if (outline.headings.length === 0 || typeof IntersectionObserver === "undefined") return; if (headings.length === 0 || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
const visible = entries const visible = entries
.filter((entry) => entry.isIntersecting) .filter((entry) => entry.isIntersecting)
@@ -142,29 +147,41 @@ export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
const id = visible[0]?.target.id; const id = visible[0]?.target.id;
if (id) setActiveId(id); if (id) setActiveId(id);
}, { rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] }); }, { rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] });
for (const item of outline.headings) { for (const item of headings) {
const node = document.getElementById(item.id); const node = document.getElementById(item.id);
if (node) observer.observe(node); if (node) observer.observe(node);
} }
return () => observer.disconnect(); return () => observer.disconnect();
}, [outline.headings]); }, [headings]);
return (
<nav aria-label="报告目录" className="personal-report-toc">
<details className="personal-report-toc-drawer">
<summary></summary>
<TocList activeId={activeId} items={headings} />
</details>
<div className="personal-report-toc-desktop">
<p></p>
<TocList activeId={activeId} items={headings} />
</div>
</nav>
);
}
export function PersonalReportMarkdownView({ markdown }: { markdown: string }) {
const outline = useMemo(() => buildLongformOutline(markdown), [markdown]);
const lead = useMemo(
() => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings) : null),
[outline],
);
return ( return (
<div className="personal-report-md-layout"> <div className="personal-report-md-layout">
<nav aria-label="报告目录" className="personal-report-toc"> <ReportToc headings={outline.headings} />
<details className="personal-report-toc-drawer">
<summary></summary>
<TocList activeId={activeId} items={outline.headings} />
</details>
<div className="personal-report-toc-desktop">
<p></p>
<TocList activeId={activeId} items={outline.headings} />
</div>
</nav>
<article className="personal-report-document personal-report-md-article"> <article className="personal-report-document personal-report-md-article">
{outline.leadMarkdown ? ( {lead ? (
<section className="personal-report-md-lead"> <section className="personal-report-md-lead">
{renderMarkdown(outline.leadMarkdown, outline.headings)} {lead}
</section> </section>
) : null} ) : null}
{outline.sections.filter((section) => !section.eager).map((section) => ( {outline.sections.filter((section) => !section.eager).map((section) => (
@@ -0,0 +1,127 @@
const CHART_HEADING_RE = /^(?:D\d{1,3}\b|Moon Chart)/;
type HastProperties = {
className?: string | string[] | null;
[key: string]: unknown;
};
type HastNode = {
type: string;
tagName?: string;
value?: string;
properties?: HastProperties;
children?: HastNode[];
};
type HastRoot = {
type: "root";
children: HastNode[];
};
function classNames(node: HastNode | undefined): string[] {
const value = node?.properties?.className;
if (Array.isArray(value)) return value.map(String);
if (typeof value === "string") return value.split(/\s+/).filter(Boolean);
return [];
}
function hasClass(node: HastNode | undefined, name: string): boolean {
return classNames(node).includes(name);
}
function isWhitespace(node: HastNode | undefined): boolean {
return Boolean(node && node.type === "text" && !String(node.value ?? "").trim());
}
function flattenText(node: HastNode | undefined): string {
if (!node) return "";
if (node.type === "text") return String(node.value ?? "");
if (!Array.isArray(node.children)) return "";
return node.children.map(flattenText).join("");
}
function isElement(node: HastNode | undefined, tagName: string): node is HastNode {
return Boolean(node && node.type === "element" && node.tagName === tagName);
}
function isChartHeading(node: HastNode | undefined): boolean {
return isElement(node, "h4") && CHART_HEADING_RE.test(flattenText(node).trim());
}
function isChartPre(node: HastNode | undefined): boolean {
if (!isElement(node, "pre") || !Array.isArray(node.children)) return false;
const code = node.children.find((child) => isElement(child, "code"));
return hasClass(code, "language-jyotish-chart") || hasClass(code, "jyotish-chart");
}
function skipWhitespace(nodes: readonly HastNode[], index: number): number {
let cursor = index;
while (cursor < nodes.length && isWhitespace(nodes[cursor])) cursor += 1;
return cursor;
}
function element(tagName: string, className: string[], children: HastNode[]): HastNode {
return {
type: "element",
tagName,
properties: { className },
children,
};
}
function parseCard(nodes: readonly HastNode[], index: number): { card: HastNode; next: number } | null {
const heading = nodes[index];
if (!isChartHeading(heading)) return null;
const children: HastNode[] = [heading];
let cursor = skipWhitespace(nodes, index + 1);
if (isElement(nodes[cursor], "p")) {
const afterParagraph = skipWhitespace(nodes, cursor + 1);
if (isChartPre(nodes[afterParagraph])) {
children.push(nodes[cursor] as HastNode);
cursor = afterParagraph;
}
}
if (!isChartPre(nodes[cursor])) return null;
children.push(nodes[cursor] as HastNode);
return {
card: element("div", ["personal-report-chart-card"], children),
next: cursor + 1,
};
}
export function groupReportChartNodes(nodes: readonly HastNode[]): HastNode[] {
const grouped: HastNode[] = [];
let index = 0;
while (index < nodes.length) {
const first = parseCard(nodes, index);
if (!first) {
grouped.push(nodes[index] as HastNode);
index += 1;
continue;
}
const cards = [first.card];
index = first.next;
while (index < nodes.length) {
const peeked = skipWhitespace(nodes, index);
const next = parseCard(nodes, peeked);
if (!next) break;
cards.push(next.card);
index = next.next;
}
grouped.push(element(
"div",
cards.length === 1
? ["personal-report-chart-grid", "is-single"]
: ["personal-report-chart-grid"],
cards,
));
}
return grouped;
}
export function reportChartGrid() {
return function groupReportCharts(tree: HastRoot) {
if (tree?.type !== "root" || !Array.isArray(tree.children)) return;
tree.children = groupReportChartNodes(tree.children);
};
}
@@ -1,4 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react"; import React from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test"; import test from "node:test";
@@ -20,6 +21,9 @@ const SAMPLE_BLOCK: ReportChartBlock = {
], ],
}; };
const D9_BLOCK: ReportChartBlock = { ...SAMPLE_BLOCK, id: "D9", title: "D9 — Navamsa(婚盘)" };
const MOON_BLOCK: ReportChartBlock = { ...SAMPLE_BLOCK, id: "MOON", title: "Moon Chart(月亮参考盘)" };
const MARKDOWN = [ const MARKDOWN = [
"#### D1 — Rashi Chart(本命盘)", "#### D1 — Rashi Chart(本命盘)",
"", "",
@@ -34,6 +38,27 @@ const MARKDOWN = [
"```", "```",
].join("\n"); ].join("\n");
const GOLDEN = JSON.parse(
readFileSync(new URL("./fixtures/report-chart-blocks-golden.json", import.meta.url), "utf8"),
) as { blocks: ReportChartBlock[] };
function fence(block: ReportChartBlock): string {
return ["```jyotish-chart", JSON.stringify(block), "```"].join("\n");
}
function countClass(markup: string, className: string): number {
const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return (markup.match(new RegExp(`(?:class|className)="[^"]*\\b${escaped}\\b`, "g")) ?? []).length;
}
function cardInnerHtml(markup: string): string[] {
const cards: string[] = [];
const re = /<div class="personal-report-chart-card">([\s\S]*?)<\/div>/g;
let match: RegExpExecArray | null;
while ((match = re.exec(markup))) cards.push(match[1] ?? "");
return cards;
}
test("markdown view draws the North Indian component and skips engine SVG HTML", () => { test("markdown view draws the North Indian component and skips engine SVG HTML", () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
React.createElement(PersonalReportMarkdownView, { markdown: MARKDOWN }), React.createElement(PersonalReportMarkdownView, { markdown: MARKDOWN }),
@@ -46,3 +71,101 @@ test("markdown view draws the North Indian component and skips engine SVG HTML",
assert.doesNotMatch(markup, /<script/i); assert.doesNotMatch(markup, /<script/i);
assert.match(markup, new RegExp(planetDisplayLabel("Saturn", 3.4, true))); assert.match(markup, new RegExp(planetDisplayLabel("Saturn", 3.4, true)));
}); });
test("consecutive chart heading+fence pairs become one two-column grid of cards", () => {
const markdown = [
"#### D1 — Rashi Chart(本命盘)",
"",
fence(SAMPLE_BLOCK),
"",
"#### D9 — Navamsa(婚盘)",
"",
fence(D9_BLOCK),
"",
"#### Moon Chart(月亮参考盘)",
"",
"月亮盘画在本命月上,用来对照夜间事件。",
"",
fence(MOON_BLOCK),
"",
"#### Shodashvarga 星座总表",
"",
"| 分盘 | 星座 |",
"| --- | --- |",
"| D1 | Leo |",
].join("\n");
const markup = renderToStaticMarkup(
React.createElement(PersonalReportMarkdownView, { markdown }),
);
assert.equal(countClass(markup, "personal-report-chart-grid"), 1);
assert.equal(countClass(markup, "is-single"), 0);
assert.equal(countClass(markup, "personal-report-chart-card"), 3);
const cards = cardInnerHtml(markup);
assert.equal(cards.length, 3);
assert.match(cards[0] ?? "", /^<h4 class="personal-report-chart-heading">[\s\S]*<\/h4><figure class="personal-report-chart-figure">/);
assert.match(cards[1] ?? "", /^<h4 class="personal-report-chart-heading">[\s\S]*<\/h4><figure class="personal-report-chart-figure">/);
assert.match(
cards[2] ?? "",
/^<h4 class="personal-report-chart-heading">[\s\S]*<\/h4><p>月亮盘画在本命月上,用来对照夜间事件。<\/p><figure class="personal-report-chart-figure">/,
);
const withoutCards = markup.replace(/<div class="personal-report-chart-card">[\s\S]*?<\/div>/g, "");
assert.match(withoutCards, /<h4>Shodashvarga 星座总表<\/h4>/);
assert.doesNotMatch(withoutCards, /personal-report-chart-card/);
});
test("a single chart pair is wrapped in an is-single grid", () => {
const markdown = ["#### D1 — Rashi Chart(本命盘)", "", fence(SAMPLE_BLOCK)].join("\n");
const markup = renderToStaticMarkup(
React.createElement(PersonalReportMarkdownView, { markdown }),
);
assert.equal(countClass(markup, "personal-report-chart-grid"), 1);
assert.equal(countClass(markup, "is-single"), 1);
assert.equal(countClass(markup, "personal-report-chart-card"), 1);
});
test("golden 22 chart figures all sit inside chart cards", () => {
const markdown = GOLDEN.blocks.map((block) => [
`#### ${block.title}`,
"",
`<svg viewBox="0 0 420 480"></svg>`,
"",
fence(block),
"",
].join("\n")).join("\n");
const markup = renderToStaticMarkup(
React.createElement(PersonalReportMarkdownView, { markdown }),
);
assert.equal(countClass(markup, "personal-report-chart-figure"), 22);
assert.equal(countClass(markup, "personal-report-chart-card"), 22);
const leftover = markup.replace(/<div class="personal-report-chart-card">[\s\S]*?<\/div>/g, "");
assert.doesNotMatch(leftover, /personal-report-chart-figure/);
});
test("chart layout CSS does not float personal-report-chart boxes", () => {
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "");
for (const chunk of css.split("}")) {
if (!chunk.includes("personal-report-chart")) continue;
assert.doesNotMatch(chunk, /float\s*:/);
assert.doesNotMatch(chunk, /margin-left\s*:\s*-50%/);
}
});
test("article markdown trees are memoized and hold no scroll state", () => {
const src = readFileSync(
new URL("../src/components/personal-report/personal-report-markdown-view.tsx", import.meta.url),
"utf8",
);
const viewStart = src.indexOf("export function PersonalReportMarkdownView");
const viewEnd = src.indexOf("\nfunction TocList", viewStart);
assert.ok(viewStart >= 0 && viewEnd > viewStart);
const view = src.slice(viewStart, viewEnd);
assert.doesNotMatch(view, /\buseState\b/);
const lines = src.split("\n");
lines.forEach((line, index) => {
if (!line.includes("renderMarkdown(")) return;
if (line.includes("function renderMarkdown")) return;
const window = lines.slice(Math.max(0, index - 6), index + 1).join("\n");
assert.match(window, /useMemo\s*\(/, `renderMarkdown at line ${index + 1} must be inside useMemo`);
});
});