Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e73168f7ff | ||
|
|
e0c6070bef |
@@ -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,不展开」,不进时间条,子运列表不渲染。
|
||||
@@ -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`。本清单未走完之前,不能写成浏览器已验收。
|
||||
@@ -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 先显式呈现登录句再跳转。同一会话再次进入直接用模块缓存,不重放等待句。出生资料保存成功后,缓存按账户和资料指纹换键,旧盘不得留在页上冒充新的日期或时间。
|
||||
|
||||
@@ -24,7 +24,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
- 星历事件还在取:「换座和停滞还在取。」
|
||||
- 星历这一天没取到:「这一天的星历没取到,点日期再试一次。」
|
||||
- 0 年大运不展开:「本段时长为 0,不展开。」时长本身写「0 年」。
|
||||
- 0 年大运不展开:「本段时长为 0,不展开。」时长本身写「0 年」。这一行是灰色,不能点开。
|
||||
- 大运「现在」卡只报三件事:大运名称、小运名称、`小运剩余 N 个月`。没有落入已返回的时段时,大运写「未落入已返回的时段」,小运和小运剩余写「—」。不解释吉凶。
|
||||
- 西洋盘要点只报位置,例如:`上升 处女 21°40′`、`太阳 双子 23°48′`、`月亮 双鱼 10°55′`、`元素最多 土`、`星群 摩羯`。同一星座少于 3 颗就不写星群。点中的行星没有主要相位时,相位表写「没有主要相位」。
|
||||
|
||||
没有出生资料时,标题是「还没有星盘资料」,按钮是「去填写出生资料」。资料读不出来、已采用时间缺时区、时区没有对上,正文仍用各自的原句,只共用这颗按钮。
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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|白羊/);
|
||||
});
|
||||
Reference in New Issue
Block a user