Compare commits

...
Author SHA1 Message Date
jesse-ux e73168f7ff docs: point the dasha and western redesign at its commit 2026-09-25 03:00:02 +08:00
jesse-ux e0c6070bef feat: fold dasha periods and redraw the western wheel
The dasha tab leads with a now card and a duration bar, and keeps sub-periods inside the current details element. A zero-year Chara row stays grey, shows 0 年, and cannot expand. The tropical wheel uses a 520 view box, element bands, separated planet labels, and aspect lines that stay visible while a selected body is highlighted.
2026-09-25 02:59:18 +08:00
17 changed files with 1305 additions and 104 deletions
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-09-25 — 星盘大运改为折叠,西洋盘重画
- 大运每条轨道先给「现在」卡和时间条,子运收在当前段里。0 年段仍是灰色「0 年」,不占时间轴,不能展开。
- 西洋盘改成 520 见方的三圈盘:星座按元素上色,行星避开重叠,宫位号放在宫中,相位线一直显示。点一颗星只突出它和它的相位。
- 右侧新增行星位置表。表格不再沿用印度盘那张 34rem 宽表。
- Skill 版本不 bump。浏览器和部署未验收。
## 2026-09-25 — 新报告正文改为读者版
- 新生成的个人长报告正文改为上游读者版,不再把专业参考/审计版当正文,也不再提供那条用户入口。已经生成的报告不重算。
@@ -0,0 +1,49 @@
# PROGRESS · 星盘大运 Tab 与西洋盘 Tab 改版(2026-09-25)
## 当前结论
T1、T2、T3 都已完成。让步 1 用了第二层半径,但触发条件比任务书写的「≥6 颗落在 30° 内」更宽:虚构 1990-01-01 的摩羯是 5 颗,推到 11° 之后 17 号字仍然叠,所以改用 188 / 136 两层半径,没有把字号降到 12 以下。让步 2 用了:`view.western` 没有 MC 字段,盘内 MC 取第 10 宫头。在这份 Placidus 引擎输出里,第 10 宫头就是 MC。没有改接口、引擎、`scripts/jaimini.py`、`HEAVY_COMPUTE_PATHS`、数据库或 workflow。没有开 BUG 号。BUG-1029 开工时仍空着,本单不用。
浏览器登录验收没做。没有推 `staging`,也没有推 `main`。实现提交是 `e0c6070befff5fcb1287db962cffec2bcf7d6c70`。功能分支可以备份到 origin。
- 工作树:`G:\Ferti\Jyotisha\.worktrees\chart-dasha-western-redesign-20260924`
- 分支:`codex/chart-dasha-western-redesign-20260924`
- 基线:`origin/staging` = `9167d75e`(0 年大运实现 `19ea0f06` 已在其中)
## 逐项
| 项 | 结果 | 说明 |
| --- | --- | --- |
| T1 | 完成 | 每轨一张「现在」卡、一条时间条。golden 上时间条段数等于大运数。只有当前段 `<details open>`。0 年段灰色、写「0 年」、不是 `<details>`,时间条上没有它。 |
| T2 | 完成 | `layoutWesternLabels` 在 1990-01-01 引擎黄经上把任意两颗显示角拉开到 ≥ 11°,标签框在 520 viewBox 内且互不重叠。宫位号在宫中。点选过滤相位表。骨架只有三圈和 12 条分割线。 |
| T3 | 完成 | DESIGN §15、VOICE、CHANGELOG、本记录、真人清单、状态板。 |
## 缺的 western 字段
没有向 `/api/chart-view` 或 `/api/western` 要新字段。
- 没有 `mc`。原始包有 `natal.mc`,mapper 没往 `view.western` 放。盘内 MC 用第 10 宫 `longitude`。
- 没有行星符号字段。符号按现有 `id` 映射,天王、海王、冥王不在那套 9 字形字体里,落到系统符号字体。
- 没有星群字段。同一 `signLabel` 至少 3 颗才写「星群」。
- `modes` 仍在 `view.western` 上,D11 的 chips 没有模式,页面不再画「元素 / 模式」那一段计数。
- 相位的 `left` / `right` 已是中文名,点选按中文名对,不是行星 id。
- 大运「现在」卡的月份用 mapper 已经在用的 `asOf`,加在 `view.dasha.asOf` 上,不是新的引擎请求。
## 测试
改代码前,在 `frontend`:
- `npx tsx --test tests/chart-page-view.test.tsx`:19 通过,0 失败
- `npx tsx --test tests/chart-view-zero-year.test.ts`:3 通过,0 失败
改完后同一组加上 `tests/western-wheel-layout.test.ts`、`tests/dark-theme-contract.test.ts`、`tests/class-name-definition-contract.test.ts`、`tests/chart-view-route.test.ts`:失败 0。改前那 22 条里没有新增失败。
- `.\node_modules\.bin\tsc --noEmit`:通过。
- `npm run lint`:0 error,121 条既有 warning。本次没有新增 warning,也没有为了清 warning 改业务代码。
- 没有改既有断言的期望值,所以没有「原值 / 新值 / 原因」三栏。新增的是大运折叠、西洋过滤和标签几何。
- 没有跑 `npm test`,没有跑 `next build`。源码没有把 `/chart` 改成动态路由,但不能声称本机构建复核过 Static。
- 没有登录浏览器,375 / 1280、摩羯星群、点行星、暗色都写在 `docs/testing/chart-dasha-western-20260924.md`,结果是待验。
## 0 年段
仍不可展开。显示「0 年」和「本段时长为 0,不展开」,不进时间条,子运列表不渲染。
+1 -1
View File
@@ -202,7 +202,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `TASK-chart-dasha-western-redesign-20260924.md` | — | **大运 / 西洋盘改版**(原型 https://claude.ai/artifact/DYovEPMqD1xwNgna5xVTre ,产品 09-24 全部同意,相位线常显):大运「现在」卡 + 时间条 + 默认折叠(原 81/144 行平铺);西洋盘 520 三圈、行星避让 + 引线、宫号居宫中、ASC/MC 盘内、行星位置表、点选过滤相位。排在 chart-ephemeris-fixes T1 之后 | 待领取 | — |
| `TASK-chart-dasha-western-redesign-20260924.md` | `PROGRESS-chart-dasha-western-redesign-20260924.md` | **大运 / 西洋盘改版**(原型 https://claude.ai/artifact/DYovEPMqD1xwNgna5xVTre ,产品 09-24 全部同意,相位线常显):大运「现在」卡 + 时间条 + 默认折叠(原 81/144 行平铺);西洋盘 520 三圈、行星避让 + 引线、宫号居宫中、ASC/MC 盘内、行星位置表、点选过滤相位。排在 chart-ephemeris-fixes T1 之后 | 待验收 | `e0c6070b` |
| `TASK-chart-ephemeris-fixes-20260924.md` | `PROGRESS-chart-ephemeris-fixes-20260924.md` | **Chara 0 年大运崩溃 + 星历切日期旧数据 + 星盘无资料空状态(BUG-1023/1024/1025)**:mapper 0/0 → `RangeError`(约 7.8% 星盘,产品定照实显示 0 年、不改引擎);BUG-966 删掉重置致日期与数据分叉(回归);无资料只有一句话,加「去填写出生资料」+ `?settings=chart` 深链(T3 排在 home-slow-network 之后)。大运 / 西洋版式重做不在本单 | 待验收 | `19ea0f06` |
| `TASK-chart-page-skeleton-wait-20260924.md` | `PROGRESS-chart-page-skeleton-wait-20260924.md` | **星盘页骨架盘等待态 + 三个「永远还没拿到」死角**:「这一张盘还没拿到。」只代表 `view === null`,是唯一等待态,失败各有句子;但客户端无超时(A)、401 跳转前(B)、分盘层失败 / 429 后 `ChartVedicTab` 只看 `!selected` 不看 pending(C)三条路都让这句永远挂着。**产品 09-24 拍板推翻 09-18 方案一(BUG-966)**:星盘页盘位用骨架盘 + 呼吸动效(复用 `VedicChartSvg` 空模型,不复制几何;`prefers-reduced-motion` 静止),范围只限星盘页会出盘轮的位置,首页 / 侧栏 / 星历 / 报告的「不放骨架」合同不动;`AGENTS.md` §6 与 `DESIGN.md` §9 / §15 追加例外。三死角出口:客户端 25s 超时 → `timedOut` 句、401 先写失败体、按层失败态 `layerFailures`(429 独立档,BUG-715/723)。BUG-1016 / 1017 | 已验收(Claude 09-24:红线全绿、+46 测试、失败名单同基线、四路由标记与 gzip 同基线;BUG-1016/1017 resolved) | `1420471a`(staging 已部署);真机清单欠 |
| `TASK-smalltalk-test-stdout-mock-20260921.md` | `PROGRESS-smalltalk-test-stdout-mock-20260921.md` | **测试基础设施(BUG-995)**:`consultation-smalltalk.test.ts` 为验证「SDK 不得把私密文本写进日志」,用 `t.mock.method(process.stdout/stderr, "write", …)` 全局接管标准输出——而 `node:test` 的 TAP 报告也走 `process.stdout`,于是运行器自己的 `# Subtest:` / `ok` 行被吞进测试的 `logs` 数组。实测四次全量:`default Mastra adapter` 四条用例只上报 4 / 2 / 1 / 1 条(`Subtest` 声明本身就少,不是 grep 锚点问题),`# tests` 因此 ±3 抖;单跑该文件 33 条静态展开只报 30 条,仅循环最后一个 `provider_error` 稳定可见。**更要紧**:注入必失败探针后,`invalid_schema` / `bad_json` 只剩一行 `not ok 1 - <文件绝对路径>`,**没有用例名、没有断言消息**(退出码仍是 1,门禁不会漏掉失败,不夸大)。定位 BUG-987 时靠的就是从门禁日志 grep `not ok` 抓用例名,红在这三条上那条路会断。**决策**:mock 改为记录后**透传给原始 `write`**(保留隐私断言,不换报告通道,不改 `npm test` 脚本);F3 把「比用例名列表 diff、不比 `# tests` 总数」写进 `frontend/AGENTS.md`。全仓只此一个文件用了这个手法(已 grep 确认)| 待验收 | `2503c019` |
@@ -0,0 +1,14 @@
# 星盘大运与西洋盘改版(2026-09-25)
状态:本环境没有登录浏览器,下面每条都还没在真人浏览器里走过。自动化锁了渲染、折叠、0 年段和标签几何,不能代替这张表。只用虚构盘:1990-01-01 12:00 北京,以及已入库的 1985-08-03 03:59 北京。不要写入真实出生资料。
| 项目 | 操作 | 通过标准 | 结果 |
| --- | --- | --- | --- |
| 窄屏 375 | 打开星盘,点「大运」,再点「西洋盘」 | 大运上下排,不出现整页横滚。西洋盘不超过屏宽,相位表和行星表在自己的盒子里横滚。可点的大运行和表行至少 44px。没有转圈或「正在加载」 | 待验 |
| 宽屏 1280 | 同一张盘,窗口放到 1280 | 大运左 Vimshottari、右 Chara。西洋盘居中且不超过 520px,右栏依次是要点、行星位置、主要相位、口径说明 | 待验 |
| 摩羯星群 | 用虚构 1990-01-01 12:00 北京打开西洋盘 | 摩羯里的行星名和度数不互相压住,也不压星座名、宫位号。盘外没有单独一个「升」字。ASC、MC 在盘内 | 待验 |
| 点行星 | 点盘上的太阳,再点一次;也点行星表里的同一行 | 相位线一直都在。点中后只突出太阳和它的相位,相位表只剩太阳。再点恢复全部 | 待验 |
| 0 年大运 | 用虚构 1985-08-03 03:59 北京打开大运 | 射手那段是灰色「0 年」,不能展开,没有子运。时间条上没有这段。别的当前段默认展开 | 待验 |
| 暗色 | 系统或页面切到暗色,看西洋盘星座环 | 火土风水四色还能分清,星座名在色块上读得清。相位的绿和红不是亮色模式下的那两个深色 | 待验 |
部署后还要核对 staging `/api/health` 的 `deployment.gitCommit`。本清单未走完之前,不能写成浏览器已验收。
+29
View File
@@ -920,6 +920,35 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
0 年大运照实显示,不改引擎。某段 Chara 的 `duration_years` 为 0 时,这一段标注「0 年」,起止落在同一天,不占时间轴,也不展开子运;子运区写「本段时长为 0,不展开」。前一段的结束日就是下一段的开始日。
### 大运 Tab(2026-09-25)
每条轨道自上而下是「现在」卡、时间条、可折叠大运列表。≥1024 时 Vimshottari 在左、Chara 在右;更窄则上下排。底部仍是那句双轨说明。
「现在」卡三行:大运、小运、小运剩余 N 个月。月份按排盘所用的 `asOf` 到当前小运结束日的整月数,结束日当天不往下减。没有落入的时段写「未落入已返回的时段」,没有小运写「—」。
时间条只画时长大于 0 的段,宽度按时长。当前段用主文字色内描边,一条竖线标 `asOf`。两端是 `YYYY.MM`。0 年段不进这条轴。段色轮换现有的浅底 token(`--color-action-soft`、`--color-success-muted`、`--color-canvas-strong`、`--color-danger-muted`、`--color-selected`、`--color-canvas-muted`),不新造一套色。
列表用原生 `<details>` / `<summary>`。只有当前段带 `open`。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。`summary` 与子运行高 44px。未展开的子运列表用 `details:not([open]) > ul { display: none }` 收起。0 年段不是 `<details>`,文字用 `--color-ink-tertiary`,只写「0 年」和「本段时长为 0,不展开」。
### 西洋盘 Tab(2026-09-25)
盘 `viewBox` 是 `0 0 520 520`,容器 `width: 100%`、`max-width: 520px`、水平居中。三圈:外圈星座、中圈行星、内圈宫位号。骨架模式只画这三圈和 12 条星座分割线,不画星座名、行星、宫位号、相位。
星座环按元素填色,token 在 `:root` 与两套暗色里各一份:
| token | 亮色 | 暗色 | 星座 |
| --- | --- | --- | --- |
| `--color-element-fire` | `#f4d3c3` | `#5a3a32` | 白羊、狮子、射手 |
| `--color-element-earth` | `#e7dfc6` | `#4a4636` | 金牛、处女、摩羯 |
| `--color-element-air` | `#d5e2f2` | `#2e3c4e` | 双子、天秤、水瓶 |
| `--color-element-water` | `#d3e6e8` | `#2a4448` | 巨蟹、天蝎、双鱼 |
星座名用 `--color-ink`。这四个填色不是通用文字底,三级字和强调色不铺在上面。相位:合 / 六分 / 三分用 `--color-success`,刑 / 冲用 `--color-danger`。组件里不写死色值。
行星标的是符号加星座内整度,逆行再加 ℞。字号 17(viewBox 单位),在 375px 宽的盘上仍不低于 12px。相邻显示角小于 11° 时沿黄经推开。推开后同一半径的字框仍会叠,就改用第二层半径(188 与 136),不再把字号降到 12 以下。真实位置在星座环内缘留刻度,并用细线连到显示位置。宫位号放在宫头到下一宫头的中点,不压在宫头线上。1 / 4 / 7 / 10 宫头加粗。ASC、MC 写在盘内;不再画盘外的「升」。`view.western` 没有 MC 字段,MC 用第 10 宫头。
相位线一直画着。点行星或行星位置表的一行,只留下该星和它的相位,其余变淡;再点取消。相位表同步只列该星。右栏顺序:要点 chips(上升、太阳、月亮、元素最多、星群)→ 行星位置表 → 主要相位表 → 口径说明。星群是同一星座里至少 3 颗行星,由现有 `planets` 数出来,不是新字段。表用 `.chart-page-western-table`,不继承 `min-width: 34rem`,外包 `.chart-page-western-table-wrap`(`min-width: 0`、`overflow-x: auto`)。断点仍是 1024。
### 等待态
2026-09-24 产品决策有限推翻 09-18 方案一(BUG-716 / 966):`SecondaryPageShell` 标题、note 槽与撑满内容区不变,主盘未到时正常渲染 body 和禁用的 Tab 行;仅 D1、非 D1 分盘与西洋盘位置显示既有 SVG 空模型的骨架与原等待句。骨架无星体、度数、星座标签,几何不得复制;容器 `aria-busy=true`,装饰盘对读屏隐藏。中性 token、opacity 0.35 ↔ 0.6 / 2.4s ease-in-out,减弱动态效果时静止 0.5;不旋转、不扫光。大运、七政表格仍为静态句,首页、侧栏、星历、报告不适用此例外。首次请求(含预取)和按需层均有 25 秒客户端期限;失败移除骨架,再点该 Tab/chip 清失败并重试。401 先显式呈现登录句再跳转。同一会话再次进入直接用模块缓存,不重放等待句。出生资料保存成功后,缓存按账户和资料指纹换键,旧盘不得留在页上冒充新的日期或时间。
+3 -1
View File
@@ -24,7 +24,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
- 星历事件还在取:「换座和停滞还在取。」
- 星历这一天没取到:「这一天的星历没取到,点日期再试一次。」
- 0 年大运不展开:「本段时长为 0,不展开。」时长本身写「0 年」。
- 0 年大运不展开:「本段时长为 0,不展开。」时长本身写「0 年」。这一行是灰色,不能点开。
- 大运「现在」卡只报三件事:大运名称、小运名称、`小运剩余 N 个月`。没有落入已返回的时段时,大运写「未落入已返回的时段」,小运和小运剩余写「—」。不解释吉凶。
- 西洋盘要点只报位置,例如:`上升 处女 21°40′`、`太阳 双子 23°48′`、`月亮 双鱼 10°55′`、`元素最多 土`、`星群 摩羯`。同一星座少于 3 颗就不写星群。点中的行星没有主要相位时,相位表写「没有主要相位」。
没有出生资料时,标题是「还没有星盘资料」,按钮是「去填写出生资料」。资料读不出来、已采用时间缺时区、时区没有对上,正文仍用各自的原句,只共用这颗按钮。
+196 -7
View File
@@ -141,6 +141,12 @@
--color-planet-saturn: #5b6bb0;
--color-planet-node: #6b7280;
--color-planet-ascendant: #b0413e;
/* Zodiac-band fills. Sign names sit on them in --color-ink, not as general
text surfaces. Dark values are in both dark blocks. */
--color-element-fire: #f4d3c3;
--color-element-earth: #e7dfc6;
--color-element-air: #d5e2f2;
--color-element-water: #d3e6e8;
--shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2);
--shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18);
/* Display and body share one sans stack. The old value led with two Anthropic
@@ -295,6 +301,10 @@
--color-planet-saturn: #8d9be0;
--color-planet-node: #a1a7b3;
--color-planet-ascendant: #e07b78;
--color-element-fire: #5a3a32;
--color-element-earth: #4a4636;
--color-element-air: #2e3c4e;
--color-element-water: #2a4448;
}
}
@@ -379,6 +389,10 @@
--color-planet-saturn: #8d9be0;
--color-planet-node: #a1a7b3;
--color-planet-ascendant: #e07b78;
--color-element-fire: #5a3a32;
--color-element-earth: #4a4636;
--color-element-air: #2e3c4e;
--color-element-water: #2a4448;
}
@@ -5062,9 +5076,75 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
.chart-page-planet-card p { margin: 0 0 var(--space-2); font-size: var(--type-body-sm); }
.chart-page-symbol { color: var(--color-ink-tertiary); }
.chart-page-dasha-now {
display: grid;
gap: var(--space-2);
margin: var(--space-3) 0;
padding: var(--space-3);
border-radius: var(--radius-sm);
background: var(--color-canvas-muted);
}
.chart-page-dasha-now h4 {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
font-weight: 500;
}
.chart-page-dasha-now dl { display: grid; gap: var(--space-1); margin: 0; }
.chart-page-dasha-now div {
display: flex;
justify-content: space-between;
gap: var(--space-3);
align-items: baseline;
}
.chart-page-dasha-now dt {
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
}
.chart-page-dasha-now dd {
margin: 0;
color: var(--color-ink);
font-size: var(--type-body-sm);
font-variant-numeric: tabular-nums;
text-align: right;
}
.chart-page-dasha-axis { margin: 0 0 var(--space-4); }
.chart-page-dasha-bar-wrap { position: relative; }
.chart-page-dasha-bar {
display: flex;
height: 12px;
overflow: hidden;
border-radius: 999px;
background: var(--color-canvas);
gap: 1px;
}
.chart-page-dasha-segment { min-width: 0; height: 100%; }
.chart-page-dasha-segment:nth-child(6n + 1) { background: var(--color-action-soft); }
.chart-page-dasha-segment:nth-child(6n + 2) { background: var(--color-success-muted); }
.chart-page-dasha-segment:nth-child(6n + 3) { background: var(--color-canvas-strong); }
.chart-page-dasha-segment:nth-child(6n + 4) { background: var(--color-danger-muted); }
.chart-page-dasha-segment:nth-child(6n + 5) { background: var(--color-selected); }
.chart-page-dasha-segment:nth-child(6n + 6) { background: var(--color-canvas-muted); }
.chart-page-dasha-segment.is-current { box-shadow: inset 0 0 0 2px var(--color-ink); }
.chart-page-dasha-today {
position: absolute;
top: -2px;
width: 2px;
height: 16px;
background: var(--color-ink);
transform: translateX(-1px);
}
.chart-page-dasha-scale {
display: flex;
justify-content: space-between;
margin-top: var(--space-1);
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
font-variant-numeric: tabular-nums;
}
.chart-page-dasha-list { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--space-2); }
.chart-page-dasha-period {
padding: var(--space-3);
padding: var(--space-2) var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius-sm);
}
@@ -5073,6 +5153,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
border-color: var(--color-border-strong);
box-shadow: inset 2px 0 0 var(--color-focus);
}
.chart-page-dasha-period.is-zero { color: var(--color-ink-tertiary); }
.chart-page-dasha-period.is-zero span { margin-left: auto; }
.chart-page-dasha-period p {
display: flex;
flex-wrap: wrap;
@@ -5080,14 +5162,53 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
margin: 0;
font-size: var(--type-body-sm);
}
.chart-page-dasha-period summary {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 44px;
cursor: pointer;
list-style: none;
font-size: var(--type-body-sm);
}
.chart-page-dasha-period summary::-webkit-details-marker { display: none; }
.chart-page-dasha-period summary::before {
content: "";
width: 0.45em;
height: 0.45em;
flex: 0 0 auto;
border-right: 1.5px solid var(--color-ink-tertiary);
border-bottom: 1.5px solid var(--color-ink-tertiary);
transform: rotate(-45deg);
}
.chart-page-dasha-period details[open] > summary::before { transform: rotate(45deg); }
.chart-page-dasha-period details:not([open]) > ul { display: none; }
.chart-page-dasha-summary-label { min-width: 0; }
.chart-page-dasha-dates {
margin-left: auto;
color: var(--color-ink-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.chart-page-dasha-period ul {
margin: var(--space-2) 0 0;
margin: 0;
padding: 0;
list-style: none;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.chart-page-dasha-period ul li.is-current { color: var(--color-ink); font-weight: 500; }
.chart-page-dasha-antar {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 44px;
}
.chart-page-dasha-antar.is-current { color: var(--color-ink); font-weight: 500; }
.chart-page-western-wheel {
width: 100%;
max-width: 520px;
margin-inline: auto;
}
.chart-page-western-svg { display: block; width: 100%; height: auto; color: var(--color-ink); }
.chart-page-western-ring,
.chart-page-western-spoke,
@@ -5096,10 +5217,78 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
stroke: var(--color-border-strong);
stroke-width: 1.25;
}
.chart-page-western-core { fill: var(--color-canvas-muted); stroke: var(--color-border-strong); }
.chart-page-western-svg text { fill: currentColor; font-size: 16px; }
.chart-page-western-house-label,
.chart-page-western-asc { fill: var(--color-ink-tertiary); }
.chart-page-western-house.is-axis { stroke: var(--color-ink-secondary); stroke-width: 2.4; }
.chart-page-western-sign { stroke: none; }
.chart-page-western-sign[data-element="fire"] { fill: var(--color-element-fire); }
.chart-page-western-sign[data-element="earth"] { fill: var(--color-element-earth); }
.chart-page-western-sign[data-element="air"] { fill: var(--color-element-air); }
.chart-page-western-sign[data-element="water"] { fill: var(--color-element-water); }
.chart-page-western-svg text { fill: currentColor; font-size: 17px; }
.chart-page-western-sign-label { fill: var(--color-ink); }
.chart-page-western-house-label { fill: var(--color-ink-secondary); }
.chart-page-western-angle { fill: var(--color-ink); font-weight: 600; }
.chart-page-western-planet { cursor: pointer; }
.chart-page-western-planet text {
font-family: var(--font-planet-glyphs), "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", sans-serif;
}
.chart-page-western-hit { fill: var(--color-canvas); opacity: 0; }
.chart-page-western-leader,
.chart-page-western-tick {
fill: none;
stroke: var(--color-ink-tertiary);
stroke-width: 0.8;
}
.chart-page-western-aspect { fill: none; stroke-width: 1.15; }
.chart-page-western-aspect[data-tone="soft"] { stroke: var(--color-success); }
.chart-page-western-aspect[data-tone="hard"] { stroke: var(--color-danger); }
.chart-page-western-aspect[data-tone="other"] { stroke: var(--color-border-strong); }
.chart-page-western-svg[data-filtering="true"] .chart-page-western-planet:not(.is-selected),
.chart-page-western-svg[data-filtering="true"] .chart-page-western-leader:not([data-active="true"]),
.chart-page-western-svg[data-filtering="true"] .chart-page-western-tick { opacity: 0.28; }
.chart-page-western-svg[data-filtering="true"] .chart-page-western-aspect:not([data-active="true"]) { opacity: 0.12; }
.chart-page-western-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: 0 0 var(--space-4);
padding: 0;
list-style: none;
}
.chart-page-western-chips li {
padding: var(--space-2) var(--space-3);
border-radius: 999px;
background: var(--color-canvas-muted);
color: var(--color-ink);
font-size: var(--type-body-sm);
}
.chart-page-western-table-wrap {
min-width: 0;
overflow-x: auto;
overscroll-behavior-x: contain;
}
.chart-page-western-table {
width: 100%;
border-collapse: collapse;
background: var(--color-canvas);
font-size: var(--type-body-sm);
}
.chart-page-western-table th,
.chart-page-western-table td {
height: 44px;
padding: 0 var(--space-3);
border-bottom: 1px solid var(--color-border);
text-align: left;
}
.chart-page-western-table thead th {
background: var(--color-canvas-muted);
color: var(--color-ink-secondary);
font-size: var(--type-caption);
font-weight: 500;
}
.chart-page-western-num { font-variant-numeric: tabular-nums; }
.chart-page-western-table tbody tr[tabindex] { cursor: pointer; }
.chart-page-western-table tbody tr[tabindex]:hover { background: var(--color-canvas-muted); }
.chart-page-western-table tr.is-selected { background: var(--color-selected); }
.chart-page-qizheng-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -2,46 +2,135 @@ import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract";
import { CHARA_ZERO_YEAR_LABEL, CHARA_ZERO_YEAR_NOTE, CHART_VIEW_COPY } from "@/lib/chart-view-labels";
type DashaTrack = ChartViewOk["dasha"]["vimshottari"];
type DashaPeriod = DashaTrack["periods"][number];
function formatYearMonth(iso: string): string {
return `${iso.slice(0, 4)}.${iso.slice(5, 7)}`;
}
function dayMs(iso: string): number {
return Date.parse(`${iso.slice(0, 10)}T00:00:00Z`);
}
/** Whole months from `asOf` until `endIso`. The end day is exclusive in the mapper. */
export function dashaRemainingMonths(endIso: string, asOf: string): number {
const [year, month, day] = asOf.slice(0, 10).split("-").map(Number);
const [endYear, endMonth, endDay] = endIso.slice(0, 10).split("-").map(Number);
if (!year || !month || !day || !endYear || !endMonth || !endDay) return 0;
let months = (endYear - year) * 12 + (endMonth - month);
if (endDay < day) months -= 1;
return Math.max(0, months);
}
function NowCard({ periods, asOf }: { periods: readonly DashaPeriod[]; asOf: string }) {
const current = periods.find((period) => period.current && !period.zeroYear);
const antar = current?.antardashas.find((item) => item.current);
const remaining = antar ? dashaRemainingMonths(antar.end, asOf) : null;
return (
<section className="chart-page-dasha-now" aria-label="现在">
<h4>现在</h4>
<dl>
<div>
<dt>大运</dt>
<dd>{current ? current.label : "未落入已返回的时段"}</dd>
</div>
<div>
<dt>小运</dt>
<dd>{antar ? antar.label : "—"}</dd>
</div>
<div>
<dt>小运剩余</dt>
<dd>{remaining === null ? "—" : `${remaining} 个月`}</dd>
</div>
</dl>
</section>
);
}
function Timeline({ periods, asOf }: { periods: readonly DashaPeriod[]; asOf: string }) {
const visible = periods.filter((period) => !period.zeroYear);
if (visible.length === 0) return null;
const start = visible[0]!.start;
const end = visible[visible.length - 1]!.end;
const startMs = dayMs(start);
const endMs = dayMs(end);
const span = endMs - startMs;
const cursor = span > 0 ? Math.min(1, Math.max(0, (dayMs(asOf) - startMs) / span)) : 0;
return (
<div className="chart-page-dasha-axis">
<div className="chart-page-dasha-bar-wrap">
<div className="chart-page-dasha-bar" role="img" aria-label={`${formatYearMonth(start)} 到 ${formatYearMonth(end)}`}>
{visible.map((period) => {
const weight = span > 0 ? (dayMs(period.end) - dayMs(period.start)) / span : 1 / visible.length;
return (
<span
key={`${period.lord}-${period.start}`}
className={period.current ? "chart-page-dasha-segment is-current" : "chart-page-dasha-segment"}
data-dasha-segment=""
style={{ flexGrow: Math.max(1, Math.round(weight * 1000)) }}
/>
);
})}
</div>
<span className="chart-page-dasha-today" style={{ left: `${cursor * 100}%` }} />
</div>
<div className="chart-page-dasha-scale">
<span>{formatYearMonth(start)}</span>
<span>{formatYearMonth(end)}</span>
</div>
</div>
);
}
function Track({
title,
method,
currentLabel,
antardashaNote,
periods,
}: ChartViewOk["dasha"]["vimshottari"]) {
asOf,
}: DashaTrack & { asOf: string }) {
return (
<section className="chart-page-dasha-track">
<header>
<h3>{title}</h3>
{/* Both tracks ship a `method` equal to their own title, which rendered
the name twice; only show it when it actually adds something. */}
{method && method !== title ? <p>{method}</p> : null}
{/* No space, and no second 当前: the fallback labels used to start with
it themselves, which read as 「当前 当前 Chara 段…」. */}
<p>当前{currentLabel}</p>
</header>
<NowCard periods={periods} asOf={asOf} />
<Timeline periods={periods} asOf={asOf} />
<ol className="chart-page-dasha-list">
{periods.map((period) => (
<li
key={`${period.lord}-${period.start}`}
className={period.current ? "chart-page-dasha-period is-current" : "chart-page-dasha-period"}
>
<p>
<strong>{period.label}</strong>
<span>{period.zeroYear ? CHARA_ZERO_YEAR_LABEL : `${period.start} – ${period.end}`}</span>
</p>
{period.zeroYear ? (
{periods.map((period, index) => (
period.zeroYear ? (
<li key={`${period.lord}-${period.start}-${index}`} className="chart-page-dasha-period is-zero">
<p>
<strong>{period.label}</strong>
<span>{CHARA_ZERO_YEAR_LABEL}</span>
</p>
<p className="chart-page-note">{CHARA_ZERO_YEAR_NOTE}</p>
) : period.antardashas.length > 0 ? (
<ul>
{period.antardashas.map((antar) => (
<li key={`${antar.lord}-${antar.start}`} className={antar.current ? "is-current" : undefined}>
{antar.label} · {antar.start} – {antar.end}
</li>
))}
</ul>
) : null}
</li>
</li>
) : (
<li
key={`${period.lord}-${period.start}-${index}`}
className={period.current ? "chart-page-dasha-period is-current" : "chart-page-dasha-period"}
>
<details open={period.current || undefined}>
<summary>
<span className="chart-page-dasha-summary-label">{period.label}</span>
<span className="chart-page-dasha-dates">{formatYearMonth(period.start)} – {formatYearMonth(period.end)}</span>
</summary>
{period.antardashas.length > 0 ? (
<ul>
{period.antardashas.map((antar) => (
<li key={`${antar.lord}-${antar.start}`} className={antar.current ? "chart-page-dasha-antar is-current" : "chart-page-dasha-antar"}>
<span>{antar.label} ·</span>
<span className="chart-page-dasha-dates">{formatYearMonth(antar.start)} – {formatYearMonth(antar.end)}</span>
</li>
))}
</ul>
) : null}
</details>
</li>
)
))}
</ol>
{antardashaNote ? <p className="chart-page-note">{antardashaNote}</p> : null}
@@ -58,16 +147,17 @@ export function ChartDashaTab({
charaPending?: boolean;
charaFailure?: ChartViewFailureStatus;
}) {
const asOf = view.dasha.asOf;
return (
<div className="chart-page-dasha">
<div className="chart-page-dasha-columns">
<Track {...view.dasha.vimshottari} />
<Track {...view.dasha.vimshottari} asOf={asOf} />
{charaPending || charaFailure ? (
<section className="chart-page-dasha-track">
<p className="chart-page-note">{charaPending ? CHART_VIEW_COPY.waitingLayer : chartLayerFailureCopy(charaFailure!)}</p>
</section>
) : (
<Track {...view.dasha.chara} />
<Track {...view.dasha.chara} asOf={asOf} />
)}
</div>
<p className="chart-page-boundary">{view.dasha.dualTrackNote}</p>
@@ -1,9 +1,57 @@
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { COORDINATE_BOUNDARY } from "@/lib/chart-view-labels";
"use client";
import { useState } from "react";
import type { ChartViewOk, ChartViewWesternOk } from "@/lib/chart-view-contract";
import { COORDINATE_BOUNDARY, SIGN_ORDER, signZh } from "@/lib/chart-view-labels";
import { formatDegreeMinutes } from "./chart-format";
import { WesternWheelSvg } from "./western-wheel-svg";
export function ChartWesternTab({ view }: { view: ChartViewOk }) {
const STELLIUM_COUNT = 3;
type WesternAspect = ChartViewWesternOk["aspects"][number];
export function westernAspectsForLabel(aspects: readonly WesternAspect[], label: string | null): WesternAspect[] {
if (!label) return [...aspects];
return aspects.filter((aspect) => aspect.left === label || aspect.right === label);
}
function signAt(longitude: number): string {
const index = Math.floor((((longitude % 360) + 360) % 360) / 30) % 12;
return signZh(SIGN_ORDER[index] ?? "Aries");
}
function degreeInSign(longitude: number): number {
return ((longitude % 360) + 360) % 360 % 30;
}
function dominantElementLabels(elements: ChartViewWesternOk["elements"]): string[] {
const max = elements.reduce((best, item) => Math.max(best, item.count), 0);
if (max <= 0) return [];
return elements.filter((item) => item.count === max).map((item) => item.label);
}
function stelliumLabels(planets: ChartViewWesternOk["planets"]): string[] {
const counts = new Map<string, number>();
for (const planet of planets) {
counts.set(planet.signLabel, (counts.get(planet.signLabel) ?? 0) + 1);
}
return [...counts.entries()].filter(([, count]) => count >= STELLIUM_COUNT).map(([label]) => label);
}
function houseSystemLabel(houseSystem: string): string {
return houseSystem === "P" ? "Placidus" : houseSystem;
}
export function ChartWesternTab({
view,
initialSelectedId = null,
}: {
view: ChartViewOk;
initialSelectedId?: string | null;
}) {
const western = view.western;
const [selectedId, setSelectedId] = useState<string | null>(initialSelectedId);
if (western.status === "unavailable") {
return (
<div className="chart-page-unavailable">
@@ -12,42 +60,107 @@ export function ChartWesternTab({ view }: { view: ChartViewOk }) {
</div>
);
}
const selected = western.planets.find((planet) => planet.id === selectedId) ?? null;
const aspects = westernAspectsForLabel(western.aspects, selected?.label ?? null);
const elements = dominantElementLabels(western.elements);
const stelliums = stelliumLabels(western.planets);
const sun = western.planets.find((planet) => planet.id === "sun");
const moon = western.planets.find((planet) => planet.id === "moon");
function toggle(id: string) {
setSelectedId((current) => (current === id ? null : id));
}
return (
<div className="chart-page-western">
<div className="chart-page-western-layout">
<WesternWheelSvg western={western} />
<div className="chart-page-western-wheel">
<WesternWheelSvg
western={western}
selectedLabel={selected?.label ?? null}
onSelectPlanet={toggle}
/>
</div>
<div className="chart-page-western-side">
<p className="chart-page-boundary">{western.boundary}</p>
<p className="chart-page-note">{western.ayanamsaGapNote}</p>
<ul className="chart-page-western-chips">
<li>上升 {signAt(western.ascendantLongitude)} {formatDegreeMinutes(degreeInSign(western.ascendantLongitude))}</li>
{sun ? <li>太阳 {sun.signLabel} {formatDegreeMinutes(sun.degreeInSign)}</li> : null}
{moon ? <li>月亮 {moon.signLabel} {formatDegreeMinutes(moon.degreeInSign)}</li> : null}
{elements.length > 0 ? <li>元素最多 {elements.join(" · ")}</li> : null}
{stelliums.length > 0 ? <li>星群 {stelliums.join(" · ")}</li> : null}
</ul>
<section>
<h3>元素 / 模式</h3>
<p>{western.elements.map((item) => `${item.label} ${item.count}`).join(" · ")}</p>
<p>{western.modes.map((item) => `${item.label} ${item.count}`).join(" · ")}</p>
</section>
<section>
<h3>主要相位</h3>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<h3>行星位置</h3>
<div className="chart-page-western-table-wrap">
<table className="chart-page-western-table">
<thead>
<tr>
<th scope="col">星体</th>
<th scope="col">星座</th>
<th scope="col">度数</th>
<th scope="col">宫位</th>
<th scope="col">逆行</th>
</tr>
</thead>
<tbody>
{western.planets.map((planet) => {
const selectedRow = selected?.id === planet.id;
return (
<tr
key={planet.id}
data-planet-row=""
className={selectedRow ? "is-selected" : undefined}
tabIndex={0}
aria-pressed={selectedRow}
onClick={() => toggle(planet.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
toggle(planet.id);
}}
>
<td>{planet.label}</td>
<td>{planet.signLabel}</td>
<td className="chart-page-western-num">{formatDegreeMinutes(planet.degreeInSign)}</td>
<td>{planet.house ?? "—"}</td>
<td>{planet.retrograde ? "逆" : "顺"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
<section>
<h3>主要相位</h3>
<div className="chart-page-western-table-wrap">
<table className="chart-page-western-table">
<thead>
<tr>
<th scope="col">星体对</th>
<th scope="col">相位</th>
<th scope="col">容许度</th>
</tr>
</thead>
<tbody>
{western.aspects.slice(0, 12).map((aspect) => (
<tr key={`${aspect.left}-${aspect.right}-${aspect.aspect}`}>
{aspects.length === 0 ? (
<tr>
<td colSpan={3}>没有主要相位</td>
</tr>
) : aspects.map((aspect) => (
<tr key={`${aspect.left}-${aspect.right}-${aspect.aspect}`} data-aspect-row="">
<td>{aspect.left} · {aspect.right}</td>
<td>{aspect.aspectLabel}</td>
<td>{aspect.orb.toFixed(1)}°</td>
<td className="chart-page-western-num">{aspect.orb.toFixed(1)}°</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<p>宫制 {western.houseSystem === "P" ? "Placidus" : western.houseSystem}</p>
<p className="chart-page-boundary">{western.boundary}</p>
<p className="chart-page-note">{western.ayanamsaGapNote}</p>
<p className="chart-page-note">宫制 {houseSystemLabel(western.houseSystem)}</p>
</div>
</div>
</div>
@@ -0,0 +1,260 @@
/** Screen geometry for the tropical wheel. Angles stay in the existing
* orientation: the ascendant is on the left, and longitude increases clockwise.
*/
export const WESTERN_SIZE = 520;
export const WESTERN_CX = 260;
export const WESTERN_CY = 260;
export const WESTERN_R_OUTER = 252;
export const WESTERN_R_ZODIAC = 228;
export const WESTERN_R_PLANET = 188;
export const WESTERN_R_PLANET_INNER = 136;
export const WESTERN_R_HOUSE = 112;
export const WESTERN_R_NUMBER = 84;
export const WESTERN_R_ASPECT = 62;
export const WESTERN_R_ANGLE = 162;
export const WESTERN_FONT_SIZE = 17;
export const WESTERN_MIN_SEPARATION_DEG = 11;
const TEXT_PRESENTATION = "\uFE0E";
const WESTERN_GLYPH: Record<string, string> = {
sun: "☉",
moon: "☽",
mercury: "☿",
venus: `♀${TEXT_PRESENTATION}`,
mars: `♂${TEXT_PRESENTATION}`,
jupiter: "♃",
saturn: "♄",
uranus: "♅",
neptune: "♆",
pluto: "♇",
true_node: "☊",
};
export type WesternLayoutBody = {
id: string;
longitude: number;
caption: string;
};
export type WesternLayoutResult = {
id: string;
longitude: number;
displayLongitude: number;
tier: 0 | 1;
};
export type WesternLabelBox = {
left: number;
top: number;
right: number;
bottom: number;
};
export function normalizeDegrees(value: number): number {
return ((value % 360) + 360) % 360;
}
export function westernScreenAngle(longitude: number, ascendantLongitude: number): number {
return 180 - (longitude - ascendantLongitude);
}
export function westernPolar(radius: number, screenDeg: number): { x: number; y: number } {
const rad = (screenDeg * Math.PI) / 180;
return {
x: WESTERN_CX + radius * Math.cos(rad),
y: WESTERN_CY - radius * Math.sin(rad),
};
}
/** Forward midpoint from one cusp to the next, not the cusp itself. */
export function houseMidLongitude(cusp: number, nextCusp: number): number {
const start = normalizeDegrees(cusp);
const span = (normalizeDegrees(nextCusp) - start + 360) % 360;
return normalizeDegrees(start + span / 2);
}
export function westernPlanetCaption(planet: {
id: string;
label: string;
degreeInSign: number;
retrograde: boolean;
}): string {
const glyph = WESTERN_GLYPH[planet.id] ?? planet.label;
const whole = Math.floor(Math.min(29.9999, Math.max(0, planet.degreeInSign)));
return `${glyph}${whole}°${planet.retrograde ? "℞" : ""}`;
}
export function westernTextWidth(text: string, fontSize = WESTERN_FONT_SIZE): number {
let width = 0;
for (const char of text) {
const code = char.codePointAt(0) ?? 0;
if (code === 0xfe0e || code === 0xfe0f) continue;
width += code > 0xff ? fontSize : fontSize * 0.62;
}
return width;
}
export function westernLabelBox(
x: number,
y: number,
text: string,
fontSize = WESTERN_FONT_SIZE,
): WesternLabelBox {
const width = westernTextWidth(text, fontSize);
const height = fontSize;
return {
left: x - width / 2,
top: y - height / 2,
right: x + width / 2,
bottom: y + height / 2,
};
}
export function westernLabelInsideViewBox(box: WesternLabelBox, size = WESTERN_SIZE): boolean {
return box.left >= 0 && box.top >= 0 && box.right <= size && box.bottom <= size;
}
function boxesOverlap(a: WesternLabelBox, b: WesternLabelBox): boolean {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function relax(original: number[]): number[] {
const count = original.length;
const pos = original.slice();
const min = WESTERN_MIN_SEPARATION_DEG;
for (let iter = 0; iter < 8; iter += 1) {
for (let index = 1; index < count; index += 1) {
const floor = pos[index - 1]! + min;
if (pos[index]! < floor) pos[index] = floor;
}
const limit = pos[0]! + 360 - min;
if (pos[count - 1]! > limit) {
const overflow = pos[count - 1]! - limit;
for (let index = 0; index < count; index += 1) pos[index] = pos[index]! - overflow;
for (let index = count - 2; index >= 0; index -= 1) {
const ceil = pos[index + 1]! - min;
if (pos[index]! > ceil) pos[index] = ceil;
}
}
for (let index = 0; index < count; index += 1) {
const lo = index === 0 ? pos[count - 1]! - 360 + min : pos[index - 1]! + min;
const hi = index === count - 1 ? pos[0]! + 360 - min : pos[index + 1]! - min;
if (hi < lo) continue;
pos[index] = Math.min(hi, Math.max(lo, original[index]!));
}
}
return pos;
}
function separateSorted(sorted: readonly number[]): number[] {
const count = sorted.length;
if (count === 0) return [];
if (count === 1) return [normalizeDegrees(sorted[0]!)];
let cut = 0;
let largest = -1;
for (let index = 0; index < count; index += 1) {
const gap = (sorted[(index + 1) % count]! - sorted[index]! + 360) % 360;
if (gap > largest) {
largest = gap;
cut = (index + 1) % count;
}
}
const order = Array.from({ length: count }, (_, step) => (cut + step) % count);
const original: number[] = [];
for (let step = 0; step < count; step += 1) {
const longitude = sorted[order[step]!]!;
if (step === 0) original.push(longitude);
else {
let next = longitude;
while (next < original[step - 1]!) next += 360;
original.push(next);
}
}
const placed = relax(original);
const display = sorted.slice();
for (let step = 0; step < count; step += 1) display[order[step]!] = normalizeDegrees(placed[step]!);
return display;
}
function planetPoint(displayLongitude: number, ascendantLongitude: number, tier: 0 | 1) {
const radius = tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER;
return westernPolar(radius, westernScreenAngle(displayLongitude, ascendantLongitude));
}
/**
* Push planet captions apart along the ecliptic until adjacent display angles
* are at least 11°. A second radius is used only when the primary radius would
* still overlap; the font size stays at 17.
*/
export function layoutWesternLabels(
planets: readonly WesternLayoutBody[],
ascendantLongitude = 0,
): WesternLayoutResult[] {
if (planets.length === 0) return [];
const indexed = planets.map((planet, index) => ({
planet,
index,
longitude: normalizeDegrees(planet.longitude),
}));
indexed.sort((a, b) => a.longitude - b.longitude || a.planet.id.localeCompare(b.planet.id));
const display = separateSorted(indexed.map((item) => item.longitude));
const byDisplay = indexed
.map((_, index) => index)
.sort((a, b) => display[a]! - display[b]! || indexed[a]!.planet.id.localeCompare(indexed[b]!.planet.id));
const overlapCount = (tiers: readonly (0 | 1)[]) => {
const boxes = indexed.map((item, index) => {
const point = planetPoint(display[index]!, ascendantLongitude, tiers[index]!);
return westernLabelBox(point.x, point.y, item.planet.caption);
});
let count = 0;
for (let left = 0; left < boxes.length; left += 1) {
for (let right = left + 1; right < boxes.length; right += 1) {
if (boxesOverlap(boxes[left]!, boxes[right]!)) count += 1;
}
}
return count;
};
const alternate = (phase: 0 | 1) => {
const tiers = new Array<0 | 1>(indexed.length).fill(0);
byDisplay.forEach((sortedIndex, order) => {
tiers[sortedIndex] = ((order + phase) % 2) as 0 | 1;
});
return tiers;
};
const flat = new Array<0 | 1>(indexed.length).fill(0);
let tiers = flat;
if (overlapCount(flat) > 0) {
const primary = alternate(0);
const secondary = alternate(1);
tiers = overlapCount(secondary) < overlapCount(primary) ? secondary : primary;
}
const results: WesternLayoutResult[] = new Array(planets.length);
indexed.forEach((item, index) => {
results[item.index] = {
id: item.planet.id,
longitude: item.longitude,
displayLongitude: display[index]!,
tier: tiers[index]!,
};
});
return results;
}
export function westernRingSlice(inner: number, outer: number, startDeg: number, endDeg: number): string {
const sweep = ((endDeg - startDeg) % 360 + 360) % 360;
const large = sweep > 180 ? 1 : 0;
const outerStart = westernPolar(outer, startDeg);
const outerEnd = westernPolar(outer, startDeg + sweep);
const innerEnd = westernPolar(inner, startDeg + sweep);
const innerStart = westernPolar(inner, startDeg);
const fmt = (value: number) => value.toFixed(2);
return [
`M ${fmt(outerStart.x)} ${fmt(outerStart.y)}`,
`A ${outer} ${outer} 0 ${large} 0 ${fmt(outerEnd.x)} ${fmt(outerEnd.y)}`,
`L ${fmt(innerEnd.x)} ${fmt(innerEnd.y)}`,
`A ${inner} ${inner} 0 ${large} 1 ${fmt(innerStart.x)} ${fmt(innerStart.y)}`,
"Z",
].join(" ");
}
@@ -1,84 +1,263 @@
import type { ChartViewWesternOk } from "@/lib/chart-view-contract";
import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels";
import { SIGN_ORDER, signZh, type SignName } from "@/lib/chart-view-labels";
import {
WESTERN_CX,
WESTERN_CY,
WESTERN_R_ANGLE,
WESTERN_R_ASPECT,
WESTERN_R_HOUSE,
WESTERN_R_NUMBER,
WESTERN_R_OUTER,
WESTERN_R_PLANET,
WESTERN_R_PLANET_INNER,
WESTERN_R_ZODIAC,
WESTERN_SIZE,
houseMidLongitude,
layoutWesternLabels,
westernPlanetCaption,
westernPolar,
westernRingSlice,
westernScreenAngle,
} from "./western-wheel-layout";
const SIZE = 400;
const CX = SIZE / 2;
const CY = SIZE / 2;
const OUTER = 188;
const ZODIAC = 168;
const HOUSE = 128;
const INNER = 78;
const SIGN_ELEMENT: Record<SignName, "fire" | "earth" | "air" | "water"> = {
Aries: "fire",
Taurus: "earth",
Gemini: "air",
Cancer: "water",
Leo: "fire",
Virgo: "earth",
Libra: "air",
Scorpio: "water",
Sagittarius: "fire",
Capricorn: "earth",
Aquarius: "air",
Pisces: "water",
};
function polar(radius: number, deg: number): { x: number; y: number } {
const rad = (deg * Math.PI) / 180;
return { x: CX + radius * Math.cos(rad), y: CY - radius * Math.sin(rad) };
const SOFT_ASPECTS = new Set(["conjunction", "sextile", "trine"]);
const HARD_ASPECTS = new Set(["square", "opposition"]);
type WesternWheelModel = Pick<ChartViewWesternOk, "ascendantLongitude" | "houses" | "planets" | "aspects">;
export const EMPTY_WESTERN_WHEEL: WesternWheelModel = {
ascendantLongitude: 0,
houses: [],
planets: [],
aspects: [],
};
function fmt(value: number): string {
return value.toFixed(2);
}
function screenAngle(longitude: number, ascendant: number): number {
return 180 - (longitude - ascendant);
function pointToward(
from: { x: number; y: number },
to: { x: number; y: number },
stop: number,
): { x: number; y: number } {
const dx = to.x - from.x;
const dy = to.y - from.y;
const dist = Math.hypot(dx, dy) || 1;
const travel = Math.max(0, dist - stop);
return { x: from.x + (dx / dist) * travel, y: from.y + (dy / dist) * travel };
}
type WesternWheelModel = Pick<ChartViewWesternOk, "ascendantLongitude" | "houses" | "planets">;
export const EMPTY_WESTERN_WHEEL: WesternWheelModel = { ascendantLongitude: 0, houses: [], planets: [] };
function aspectTone(aspect: string): "soft" | "hard" | "other" {
if (SOFT_ASPECTS.has(aspect)) return "soft";
if (HARD_ASPECTS.has(aspect)) return "hard";
return "other";
}
export function WesternWheelSvg({ western: suppliedWestern, skeleton = false }: { western: WesternWheelModel; skeleton?: boolean }) {
export function WesternWheelSvg({
western: suppliedWestern,
skeleton = false,
selectedLabel = null,
onSelectPlanet,
}: {
western: WesternWheelModel;
skeleton?: boolean;
selectedLabel?: string | null;
onSelectPlanet?: (id: string) => void;
}) {
const western = skeleton ? EMPTY_WESTERN_WHEEL : suppliedWestern;
const asc = western.ascendantLongitude;
const signWedges = SIGN_ORDER.map((sign, index) => {
const mid = screenAngle(index * 30 + 15, asc);
return { sign, label: polar((ZODIAC + OUTER) / 2, mid) };
});
const placements = layoutWesternLabels(
western.planets.map((planet) => ({
id: planet.id,
longitude: planet.longitude,
caption: westernPlanetCaption(planet),
})),
asc,
);
const placementById = new Map(placements.map((item) => [item.id, item]));
const longitudeByLabel = new Map(western.planets.map((planet) => [planet.label, planet.longitude]));
longitudeByLabel.set("上升", asc);
const tenth = western.houses.find((house) => house.house === 10);
if (tenth) longitudeByLabel.set("中天", tenth.longitude);
return (
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
viewBox={`0 0 ${WESTERN_SIZE} ${WESTERN_SIZE}`}
width="100%"
role="img"
aria-label={skeleton ? undefined : "回归黄道本命盘,上升在左,宫位按 Placidus"}
aria-hidden={skeleton || undefined}
data-skeleton={skeleton ? "true" : undefined}
data-filtering={selectedLabel ? "true" : undefined}
className="chart-page-western-svg"
>
<circle cx={CX} cy={CY} r={OUTER} className="chart-page-western-ring" />
<circle cx={CX} cy={CY} r={ZODIAC} className="chart-page-western-ring" />
<circle cx={CX} cy={CY} r={HOUSE} className="chart-page-western-ring" />
<circle cx={CX} cy={CY} r={INNER} className="chart-page-western-core" />
{signWedges.map((wedge) => {
const start = polar(ZODIAC, screenAngle(SIGN_ORDER.indexOf(wedge.sign) * 30, asc));
const outer = polar(OUTER, screenAngle(SIGN_ORDER.indexOf(wedge.sign) * 30, asc));
{!skeleton && SIGN_ORDER.map((sign, index) => {
const start = westernScreenAngle(index * 30 + 30, asc);
const end = westernScreenAngle(index * 30, asc);
return (
<g key={wedge.sign}>
<line x1={start.x} y1={start.y} x2={outer.x} y2={outer.y} className="chart-page-western-spoke" />
{!skeleton && <text x={wedge.label.x} y={wedge.label.y} textAnchor="middle" dominantBaseline="middle">
{signZh(wedge.sign)}
</text>}
<path
key={sign}
d={westernRingSlice(WESTERN_R_ZODIAC, WESTERN_R_OUTER, start, end)}
className="chart-page-western-sign"
data-element={SIGN_ELEMENT[sign]}
/>
);
})}
<circle cx={WESTERN_CX} cy={WESTERN_CY} r={WESTERN_R_OUTER} className="chart-page-western-ring" />
<circle cx={WESTERN_CX} cy={WESTERN_CY} r={WESTERN_R_ZODIAC} className="chart-page-western-ring" />
<circle cx={WESTERN_CX} cy={WESTERN_CY} r={WESTERN_R_HOUSE} className="chart-page-western-ring" />
{SIGN_ORDER.map((sign, index) => {
const edge = westernScreenAngle(index * 30, asc);
const inner = westernPolar(WESTERN_R_ZODIAC, edge);
const outer = westernPolar(WESTERN_R_OUTER, edge);
const label = westernPolar((WESTERN_R_ZODIAC + WESTERN_R_OUTER) / 2, westernScreenAngle(index * 30 + 15, asc));
return (
<g key={`${sign}-spoke`}>
<line x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="chart-page-western-spoke" />
{!skeleton && (
<text x={label.x} y={label.y} textAnchor="middle" dominantBaseline="middle" className="chart-page-western-sign-label">
{signZh(sign)}
</text>
)}
</g>
);
})}
{western.houses.map((house) => {
const angle = screenAngle(house.longitude, asc);
const inner = polar(INNER, angle);
const outer = polar(ZODIAC, angle);
const label = polar((INNER + HOUSE) / 2, angle);
{!skeleton && western.houses.map((house, index) => {
const next = western.houses[(index + 1) % western.houses.length] ?? house;
const angle = westernScreenAngle(house.longitude, asc);
const inner = westernPolar(WESTERN_R_ASPECT, angle);
const outer = westernPolar(WESTERN_R_ZODIAC, angle);
const mid = houseMidLongitude(house.longitude, next.longitude);
const label = westernPolar(WESTERN_R_NUMBER, westernScreenAngle(mid, asc));
const axis = house.house === 1 || house.house === 4 || house.house === 7 || house.house === 10;
return (
<g key={house.house}>
<line x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="chart-page-western-house" />
<text x={label.x} y={label.y} textAnchor="middle" dominantBaseline="middle" className="chart-page-western-house-label">
<line
x1={inner.x}
y1={inner.y}
x2={outer.x}
y2={outer.y}
className={axis ? "chart-page-western-house is-axis" : "chart-page-western-house"}
/>
<text
x={label.x}
y={label.y}
textAnchor="middle"
dominantBaseline="middle"
className="chart-page-western-house-label"
data-house={house.house}
data-longitude={fmt(mid)}
>
{house.house}
</text>
</g>
);
})}
{western.planets.map((planet) => {
const point = polar((HOUSE + INNER) / 2 + 10, screenAngle(planet.longitude, asc));
{!skeleton && western.aspects.map((aspect) => {
const left = longitudeByLabel.get(aspect.left);
const right = longitudeByLabel.get(aspect.right);
if (left === undefined || right === undefined) return null;
const from = westernPolar(WESTERN_R_ASPECT, westernScreenAngle(left, asc));
const to = westernPolar(WESTERN_R_ASPECT, westernScreenAngle(right, asc));
const active = selectedLabel !== null && (aspect.left === selectedLabel || aspect.right === selectedLabel);
return (
<text key={planet.id} x={point.x} y={point.y} textAnchor="middle" dominantBaseline="middle">
{planet.label}
</text>
<line
key={`${aspect.left}-${aspect.right}-${aspect.aspect}`}
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
className="chart-page-western-aspect"
data-tone={aspectTone(aspect.aspect)}
data-active={active ? "true" : undefined}
/>
);
})}
{!skeleton && <text x={polar(OUTER + 4, 180).x - 8} y={CY} textAnchor="end" dominantBaseline="middle" className="chart-page-western-asc">
升
</text>}
{!skeleton && western.planets.map((planet) => {
const placement = placementById.get(planet.id);
if (!placement) return null;
const radius = placement.tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER;
const display = westernPolar(radius, westernScreenAngle(placement.displayLongitude, asc));
const truth = westernPolar(WESTERN_R_ZODIAC - 8, westernScreenAngle(placement.longitude, asc));
const leaderEnd = pointToward(truth, display, 14);
const selected = selectedLabel === planet.label;
const caption = westernPlanetCaption(planet);
return (
<g
key={planet.id}
className={selected ? "chart-page-western-planet is-selected" : "chart-page-western-planet"}
data-tier={placement.tier}
role="button"
tabIndex={0}
aria-pressed={selected}
aria-label={planet.label}
onClick={() => onSelectPlanet?.(planet.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onSelectPlanet?.(planet.id);
}}
>
<line
x1={truth.x}
y1={truth.y}
x2={leaderEnd.x}
y2={leaderEnd.y}
className="chart-page-western-leader"
data-active={selected ? "true" : undefined}
/>
<line
x1={westernPolar(WESTERN_R_ZODIAC, westernScreenAngle(planet.longitude, asc)).x}
y1={westernPolar(WESTERN_R_ZODIAC, westernScreenAngle(planet.longitude, asc)).y}
x2={truth.x}
y2={truth.y}
className="chart-page-western-tick"
/>
<circle cx={display.x} cy={display.y} r={22} className="chart-page-western-hit" />
<text x={display.x} y={display.y} textAnchor="middle" dominantBaseline="middle">
{caption}
</text>
</g>
);
})}
{!skeleton && (
<text
x={westernPolar(WESTERN_R_ANGLE, westernScreenAngle(asc, asc) + 9).x}
y={westernPolar(WESTERN_R_ANGLE, westernScreenAngle(asc, asc) + 9).y}
textAnchor="middle"
dominantBaseline="middle"
className="chart-page-western-angle"
>
ASC
</text>
)}
{!skeleton && tenth && (
<text
x={westernPolar(WESTERN_R_ANGLE, westernScreenAngle(tenth.longitude, asc) + 9).x}
y={westernPolar(WESTERN_R_ANGLE, westernScreenAngle(tenth.longitude, asc) + 9).y}
textAnchor="middle"
dominantBaseline="middle"
className="chart-page-western-angle"
>
MC
</text>
)}
</svg>
);
}
+3
View File
@@ -213,6 +213,9 @@ export const chartViewOkSchema = z.object({
planets: z.array(chartViewPlanetRowSchema).min(1),
}),
dasha: z.object({
// The day the current flags were computed against. The dasha tab uses it
// for the "现在" card and the timeline cursor. It is not an engine field.
asOf: isoDate,
vimshottari: chartViewDashaTrackSchema,
chara: chartViewDashaTrackSchema,
dualTrackNote: z.string().min(1),
+1
View File
@@ -695,6 +695,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
planets: planetRows(chart, varga),
},
dasha: {
asOf: isoDay(asOf),
vimshottari,
chara: charaMapped,
dualTrackNote: DASHA_DUAL_TRACK_NOTE,
+61
View File
@@ -4,7 +4,9 @@ import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { ChartDashaTab } from "../src/components/chart-page/chart-dasha-tab.tsx";
import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx";
import { ChartWesternTab } from "../src/components/chart-page/chart-western-tab.tsx";
import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg.tsx";
import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx";
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
@@ -14,6 +16,7 @@ import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts";
import { chartViewFailureResponse } from "../src/lib/chart-view-failure.ts";
import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts";
import { CHART_SETTINGS_HREF } from "../src/lib/settings-url.ts";
import { westernLabelBox } from "../src/components/chart-page/western-wheel-layout.ts";
import { cssDeclarations } from "./css-contract-test-support.ts";
Object.assign(globalThis, { React });
@@ -370,6 +373,64 @@ test("chart svgs omit the invalid height attribute and western height stays in c
assert.match(globalStyles, /\.chart-page-western-svg \{[^}]*height: auto/);
});
test("the dasha tab folds every track to the current period and keeps a now card", async () => {
const view = await okView(golden.western);
const markup = renderToStaticMarkup(React.createElement(ChartDashaTab, { view }));
assert.equal((markup.match(/chart-page-dasha-now/g) ?? []).length, 2);
assert.match(markup, /现在/);
assert.match(markup, /<dt>小运剩余<\/dt><dd>\d+ 个月<\/dd>/);
const periods = [...view.dasha.vimshottari.periods, ...view.dasha.chara.periods];
assert.equal(periods.some((period) => period.zeroYear), false);
assert.equal((markup.match(/data-dasha-segment=/g) ?? []).length, periods.length);
const details = markup.match(/<details/g) ?? [];
const open = markup.match(/<details open/g) ?? [];
assert.equal(open.length, periods.filter((period) => period.current).length);
assert.ok(details.length > open.length);
assert.match(cssDeclarations(".chart-page-dasha-period summary", globalStyles), /min-height:\s*44px/);
assert.match(globalStyles, /\.chart-page-dasha-period details:not\(\[open\]\) > ul \{ display: none; \}/);
});
test("the western tab lists every planet and filters aspects for the selected body", async () => {
const view = await okView(golden.western);
assert.equal(view.western.status, "ok");
if (view.western.status !== "ok") return;
const markup = renderToStaticMarkup(React.createElement(ChartWesternTab, { view }));
assert.equal((markup.match(/data-planet-row=/g) ?? []).length, view.western.planets.length);
assert.equal((markup.match(/data-aspect-row=/g) ?? []).length, view.western.aspects.length);
assert.match(markup, /viewBox="0 0 520 520"/);
assert.match(markup, /data-tone=/);
assert.doesNotMatch(markup, /<text[^>]*>升<\/text>/);
assert.match(markup, /上升 /);
assert.match(markup, /太阳 /);
assert.match(markup, /月亮 /);
assert.match(markup, /星群 摩羯/);
assert.match(cssDeclarations(".chart-page-western-wheel", globalStyles), /max-width:\s*520px/);
assert.match(cssDeclarations(".chart-page-western-table-wrap", globalStyles), /overflow-x:\s*auto/);
assert.match(cssDeclarations(".chart-page-western-table-wrap", globalStyles), /min-width:\s*0/);
assert.doesNotMatch(cssDeclarations(".chart-page-western-table", globalStyles), /min-width/);
const sun = view.western.planets.find((planet) => planet.id === "sun");
assert.ok(sun);
const filtered = renderToStaticMarkup(React.createElement(ChartWesternTab, { view, initialSelectedId: sun.id }));
const expected = view.western.aspects.filter((aspect) => aspect.left === sun.label || aspect.right === sun.label);
assert.equal((filtered.match(/data-aspect-row=/g) ?? []).length, expected.length);
assert.ok(expected.length < view.western.aspects.length);
assert.match(filtered, /data-filtering="true"/);
assert.match(filtered, /is-selected/);
const svg = markup.slice(markup.indexOf("<svg"), markup.indexOf("</svg>"));
const labels = [...svg.matchAll(/<text x="([^"]+)" y="([^"]+)"[^>]*>([^<]*)<\/text>/g)].map((match) => ({
text: match[3] ?? "",
box: westernLabelBox(Number(match[1]), Number(match[2]), match[3] ?? ""),
}));
for (let left = 0; left < labels.length; left += 1) {
for (let right = left + 1; right < labels.length; right += 1) {
const a = labels[left]!.box;
const b = labels[right]!.box;
const overlap = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
assert.equal(overlap, false, `${labels[left]!.text} overlaps ${labels[right]!.text}`);
}
}
});
test("the (app) layout mounts one sidebar for home and the four secondary routes", () => {
// 原值:`(secondary)/layout.tsx` 挂只读侧栏,`useSidebarData` 另拉一份列表
// 新值:`(app)/layout.tsx` 一份 SessionListProvider + SidebarProvider,首页也在里面
@@ -95,6 +95,22 @@ test("zero antardasha weights on a positive mahadasha do not produce an invalid
}
});
test("a zero-year period is grey, closed, and absent from the timeline", () => {
const view = viewFrom(golden.chara);
const markup = renderToStaticMarkup(React.createElement(ChartDashaTab, { view }));
const zero = markup.split("<li").find((item) => item.includes(CHARA_ZERO_YEAR_LABEL) && item.includes("射手"));
assert.ok(zero);
assert.match(zero, /is-zero/);
assert.doesNotMatch(zero, /<details/);
assert.match(zero, new RegExp(CHARA_ZERO_YEAR_NOTE));
const tracks = markup.split("chart-page-dasha-track").slice(1);
assert.equal(tracks.length, 2);
const charaSegments = tracks[1]?.match(/data-dasha-segment=/g) ?? [];
assert.equal(charaSegments.length, view.dasha.chara.periods.filter((period) => !period.zeroYear).length);
assert.equal(charaSegments.length, 11);
assert.doesNotMatch(tracks[1] ?? "", /<details[^>]*open[^>]*>[\s\S]*0 年/);
});
test("the zero-year Chara row shows 0 年 and does not render sub-periods", () => {
const view = viewFrom(golden.chara);
const markup = renderToStaticMarkup(React.createElement(ChartDashaTab, { view }));
+33
View File
@@ -0,0 +1,33 @@
{
"source": "scripts.western_chart_engine.build_tropical_natal_chart",
"birth": "1990-01-01 12:00 timezone 8 latitude 39.9042 longitude 116.4074 house P",
"ascendant": { "longitude": 11.837301, "sign": "Aries", "degree_in_sign": 11.837301 },
"mc": { "longitude": 276.388945, "sign": "Capricorn", "degree_in_sign": 6.388945 },
"planets": [
{ "id": "sun", "label": "太阳", "longitude": 280.474427, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 10.474427, "house": 10, "retrograde": false },
{ "id": "moon", "label": "月亮", "longitude": 328.788668, "sign": "Aquarius", "signLabel": "水瓶", "degreeInSign": 28.788668, "house": 12, "retrograde": false },
{ "id": "mercury", "label": "水星", "longitude": 295.760024, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 25.760024, "house": 10, "retrograde": true },
{ "id": "venus", "label": "金星", "longitude": 306.262927, "sign": "Aquarius", "signLabel": "水瓶", "degreeInSign": 6.262927, "house": 11, "retrograde": true },
{ "id": "mars", "label": "火星", "longitude": 249.765531, "sign": "Sagittarius", "signLabel": "射手", "degreeInSign": 9.765531, "house": 8, "retrograde": false },
{ "id": "jupiter", "label": "木星", "longitude": 95.193582, "sign": "Cancer", "signLabel": "巨蟹", "degreeInSign": 5.193582, "house": 3, "retrograde": true },
{ "id": "saturn", "label": "土星", "longitude": 285.618103, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 15.618103, "house": 10, "retrograde": false },
{ "id": "uranus", "label": "天王", "longitude": 275.765468, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 5.765468, "house": 9, "retrograde": false },
{ "id": "neptune", "label": "海王", "longitude": 282.025482, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 12.025482, "house": 10, "retrograde": false },
{ "id": "pluto", "label": "冥王", "longitude": 227.084033, "sign": "Scorpio", "signLabel": "天蝎", "degreeInSign": 17.084033, "house": 7, "retrograde": false },
{ "id": "true_node", "label": "北交", "longitude": 316.861653, "sign": "Aquarius", "signLabel": "水瓶", "degreeInSign": 16.861653, "house": 11, "retrograde": false }
],
"houses": [
{ "house": 1, "longitude": 11.837301, "sign": "Aries", "signLabel": "白羊", "degreeInSign": 11.837301 },
{ "house": 2, "longitude": 49.703156, "sign": "Taurus", "signLabel": "金牛", "degreeInSign": 19.703156 },
{ "house": 3, "longitude": 75.027826, "sign": "Gemini", "signLabel": "双子", "degreeInSign": 15.027826 },
{ "house": 4, "longitude": 96.388945, "sign": "Cancer", "signLabel": "巨蟹", "degreeInSign": 6.388945 },
{ "house": 5, "longitude": 118.786626, "sign": "Cancer", "signLabel": "巨蟹", "degreeInSign": 28.786626 },
{ "house": 6, "longitude": 147.810722, "sign": "Leo", "signLabel": "狮子", "degreeInSign": 27.810722 },
{ "house": 7, "longitude": 191.837301, "sign": "Libra", "signLabel": "天秤", "degreeInSign": 11.837301 },
{ "house": 8, "longitude": 229.703156, "sign": "Scorpio", "signLabel": "天蝎", "degreeInSign": 19.703156 },
{ "house": 9, "longitude": 255.027826, "sign": "Sagittarius", "signLabel": "射手", "degreeInSign": 15.027826 },
{ "house": 10, "longitude": 276.388945, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 6.388945 },
{ "house": 11, "longitude": 298.786626, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 28.786626 },
{ "house": 12, "longitude": 327.810722, "sign": "Aquarius", "signLabel": "水瓶", "degreeInSign": 27.810722 }
]
}
+155
View File
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import {
WESTERN_CX,
WESTERN_CY,
WESTERN_MIN_SEPARATION_DEG,
WESTERN_R_NUMBER,
WESTERN_R_PLANET,
WESTERN_R_PLANET_INNER,
WESTERN_SIZE,
layoutWesternLabels,
westernLabelBox,
westernLabelInsideViewBox,
westernPlanetCaption,
westernPolar,
westernScreenAngle,
type WesternLayoutBody,
} from "../src/components/chart-page/western-wheel-layout.ts";
import { EMPTY_WESTERN_WHEEL, WesternWheelSvg } from "../src/components/chart-page/western-wheel-svg.tsx";
Object.assign(globalThis, { React });
const fixture = JSON.parse(
readFileSync(new URL("./fixtures/western-1990-01-01-beijing.json", import.meta.url), "utf8"),
) as {
ascendant: { longitude: number };
planets: Array<WesternLayoutBody & {
label: string;
sign: string;
signLabel: string;
degreeInSign: number;
house: number;
retrograde: boolean;
}>;
houses: Array<{ house: number; longitude: number; sign: string; signLabel: string; degreeInSign: number }>;
};
function normalize(value: number): number {
return ((value % 360) + 360) % 360;
}
function forwardGap(from: number, to: number): number {
return (to - from + 360) % 360;
}
function longitudeFromPoint(x: number, y: number, ascendant: number): number {
const screen = (Math.atan2(WESTERN_CY - y, x - WESTERN_CX) * 180) / Math.PI;
return normalize(ascendant + 180 - screen);
}
function houseMid(cusp: number, next: number): number {
const start = normalize(cusp);
const span = forwardGap(start, normalize(next));
return normalize(start + span / 2);
}
function boxesOverlap(
a: { left: number; top: number; right: number; bottom: number },
b: { left: number; top: number; right: number; bottom: number },
): boolean {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
test("layoutWesternLabels separates the 1990-01-01 Capricorn cluster by at least 11 degrees", () => {
const bodies: WesternLayoutBody[] = fixture.planets.map((planet) => ({
id: planet.id,
longitude: planet.longitude,
caption: westernPlanetCaption(planet),
}));
const placed = layoutWesternLabels(bodies, fixture.ascendant.longitude);
assert.equal(placed.length, bodies.length);
const sun = fixture.planets.find((planet) => planet.id === "sun");
const neptune = fixture.planets.find((planet) => planet.id === "neptune");
assert.ok(sun && neptune);
assert.ok(forwardGap(sun.longitude, neptune.longitude) < WESTERN_MIN_SEPARATION_DEG);
const ordered = [...placed].sort((a, b) => a.displayLongitude - b.displayLongitude);
for (let index = 0; index < ordered.length; index += 1) {
const gap = forwardGap(ordered[index]!.displayLongitude, ordered[(index + 1) % ordered.length]!.displayLongitude);
assert.ok(gap >= WESTERN_MIN_SEPARATION_DEG - 1e-6, `${ordered[index]!.id} gap ${gap}`);
}
const boxes = placed.map((item) => {
const body = bodies.find((planet) => planet.id === item.id)!;
const radius = item.tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER;
const point = westernPolar(radius, westernScreenAngle(item.displayLongitude, fixture.ascendant.longitude));
const box = westernLabelBox(point.x, point.y, body.caption);
assert.equal(westernLabelInsideViewBox(box), true, item.id);
return box;
});
for (let left = 0; left < boxes.length; left += 1) {
for (let right = left + 1; right < boxes.length; right += 1) {
assert.equal(boxesOverlap(boxes[left]!, boxes[right]!), false, `${placed[left]!.id} overlaps ${placed[right]!.id}`);
}
}
});
test("house numbers sit at the house midpoint and every label stays inside the viewBox", () => {
const markup = renderToStaticMarkup(React.createElement(WesternWheelSvg, {
western: {
ascendantLongitude: fixture.ascendant.longitude,
planets: fixture.planets,
houses: fixture.houses,
aspects: [],
},
}));
assert.match(markup, /viewBox="0 0 520 520"/);
assert.doesNotMatch(markup, /<text[^>]*>升<\/text>/);
assert.match(markup, />ASC</);
assert.match(markup, />MC</);
const ascendant = fixture.ascendant.longitude;
for (let index = 0; index < fixture.houses.length; index += 1) {
const house = fixture.houses[index]!;
const next = fixture.houses[(index + 1) % fixture.houses.length]!;
const expected = houseMid(house.longitude, next.longitude);
const tag = markup.match(new RegExp(`<text x="([^"]+)" y="([^"]+)"[^>]*data-house="${house.house}"`));
assert.ok(tag, `missing house ${house.house}`);
const longitude = longitudeFromPoint(Number(tag[1]), Number(tag[2]), ascendant);
const radius = Math.hypot(Number(tag[1]) - WESTERN_CX, Number(tag[2]) - WESTERN_CY);
assert.ok(Math.abs(radius - WESTERN_R_NUMBER) < 0.2);
const delta = Math.min(forwardGap(expected, longitude), forwardGap(longitude, expected));
assert.ok(delta < 0.05, `house ${house.house} is ${longitude}, expected ${expected}`);
const cuspDelta = Math.min(forwardGap(house.longitude, longitude), forwardGap(longitude, house.longitude));
assert.ok(cuspDelta > 1, `house ${house.house} sits on its cusp`);
}
const labels = [...markup.matchAll(/<text x="([^"]+)" y="([^"]+)"[^>]*>([^<]*)<\/text>/g)].map((match) => ({
text: match[3] ?? "",
box: westernLabelBox(Number(match[1]), Number(match[2]), match[3] ?? ""),
}));
for (const label of labels) {
assert.equal(westernLabelInsideViewBox(label.box, WESTERN_SIZE), true, label.text);
}
for (let left = 0; left < labels.length; left += 1) {
for (let right = left + 1; right < labels.length; right += 1) {
assert.equal(
boxesOverlap(labels[left]!.box, labels[right]!.box),
false,
`${labels[left]!.text} overlaps ${labels[right]!.text}`,
);
}
}
});
test("the western skeleton draws three rings and spokes without labels or aspects", () => {
const markup = renderToStaticMarkup(React.createElement(WesternWheelSvg, {
western: EMPTY_WESTERN_WHEEL,
skeleton: true,
}));
assert.match(markup, /data-skeleton="true"/);
assert.equal((markup.match(/<circle /g) ?? []).length, 3);
assert.equal((markup.match(/<line /g) ?? []).length, 12);
assert.doesNotMatch(markup, /<text|<path|data-tone|ASC|MC|白羊/);
});